loading
Generated 2025-07-29T09:03:34-07:00

All Files ( 1.78% covered at 0.02 hits/line )

119 files in total.
18029 relevant lines, 321 lines covered and 17708 lines missed. ( 1.78% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
app/channels/application_cable/connection.rb 0.00 % 16 14 0 14 0.00
app/channels/brand_compliance_channel.rb 0.00 % 250 189 0 189 0.00
app/controllers/activities_controller.rb 0.00 % 37 30 0 30 0.00
app/controllers/activity_reports_controller.rb 0.00 % 79 66 0 66 0.00
app/controllers/api/v1/analytics_controller.rb 0.00 % 485 369 0 369 0.00
app/controllers/api/v1/base_controller.rb 0.00 % 49 34 0 34 0.00
app/controllers/api/v1/brand_compliance_controller.rb 0.00 % 249 210 0 210 0.00
app/controllers/api/v1/campaigns_controller.rb 0.00 % 267 213 0 213 0.00
app/controllers/api/v1/journey_steps_controller.rb 0.00 % 317 250 0 250 0.00
app/controllers/api/v1/journey_suggestions_controller.rb 0.00 % 498 433 0 433 0.00
app/controllers/api/v1/journey_templates_controller.rb 0.00 % 291 224 0 224 0.00
app/controllers/api/v1/journeys_controller.rb 0.00 % 230 193 0 193 0.00
app/controllers/api/v1/personas_controller.rb 0.00 % 471 375 0 375 0.00
app/controllers/application_controller.rb 0.00 % 19 13 0 13 0.00
app/controllers/brand_assets_controller.rb 0.00 % 169 142 0 142 0.00
app/controllers/brand_guidelines_controller.rb 0.00 % 67 55 0 55 0.00
app/controllers/brands_controller.rb 0.00 % 83 68 0 68 0.00
app/controllers/concerns/activity_trackable.rb 0.00 % 167 130 0 130 0.00
app/controllers/concerns/activity_tracker.rb 0.00 % 141 106 0 106 0.00
app/controllers/concerns/admin_auditable.rb 0.00 % 93 78 0 78 0.00
app/controllers/concerns/api_authentication.rb 0.00 % 47 36 0 36 0.00
app/controllers/concerns/api_error_handling.rb 0.00 % 61 50 0 50 0.00
app/controllers/concerns/api_pagination.rb 0.00 % 49 40 0 40 0.00
app/controllers/concerns/authentication.rb 0.00 % 95 81 0 81 0.00
app/controllers/concerns/rails_admin_auditable.rb 0.00 % 77 66 0 66 0.00
app/controllers/home_controller.rb 0.00 % 6 5 0 5 0.00
app/controllers/journey_suggestions_controller.rb 0.00 % 480 405 0 405 0.00
app/controllers/journey_templates_controller.rb 0.00 % 158 127 0 127 0.00
app/controllers/messaging_frameworks_controller.rb 0.00 % 373 306 0 306 0.00
app/controllers/passwords_controller.rb 0.00 % 43 34 0 34 0.00
app/controllers/profiles_controller.rb 0.00 % 49 39 0 39 0.00
app/controllers/rails_admin/application_controller.rb 0.00 % 27 21 0 21 0.00
app/controllers/registrations_controller.rb 0.00 % 29 22 0 22 0.00
app/controllers/sessions_controller.rb 0.00 % 66 55 0 55 0.00
app/controllers/user_sessions_controller.rb 0.00 % 26 21 0 21 0.00
app/controllers/users_controller.rb 0.00 % 18 14 0 14 0.00
app/helpers/activities_helper.rb 0.00 % 2 2 0 2 0.00
app/helpers/api/v1/brand_compliance_helper.rb 0.00 % 2 2 0 2 0.00
app/helpers/application_helper.rb 0.00 % 2 2 0 2 0.00
app/helpers/brand_assets_helper.rb 0.00 % 2 2 0 2 0.00
app/helpers/brand_guidelines_helper.rb 0.00 % 2 2 0 2 0.00
app/helpers/brands_helper.rb 0.00 % 2 2 0 2 0.00
app/helpers/home_helper.rb 0.00 % 2 2 0 2 0.00
app/helpers/journey_templates_helper.rb 0.00 % 2 2 0 2 0.00
app/helpers/messaging_frameworks_helper.rb 0.00 % 2 2 0 2 0.00
app/helpers/profiles_helper.rb 0.00 % 2 2 0 2 0.00
app/helpers/rails_admin/dashboard_helper.rb 0.00 % 48 39 0 39 0.00
app/helpers/registrations_helper.rb 0.00 % 2 2 0 2 0.00
app/helpers/user_sessions_helper.rb 0.00 % 21 19 0 19 0.00
app/helpers/users_helper.rb 0.00 % 2 2 0 2 0.00
app/jobs/activity_cleanup_job.rb 0.00 % 60 40 0 40 0.00
app/jobs/application_job.rb 0.00 % 7 2 0 2 0.00
app/jobs/brand_analysis_job.rb 0.00 % 47 31 0 31 0.00
app/jobs/brand_analysis_notification_job.rb 0.00 % 15 6 0 6 0.00
app/jobs/brand_asset_processing_job.rb 0.00 % 20 15 0 15 0.00
app/jobs/brand_compliance_job.rb 0.00 % 156 113 0 113 0.00
app/jobs/branding/compliance/cache_warmer_job.rb 0.00 % 12 11 0 11 0.00
app/jobs/journey_suggestions_cache_warmup_job.rb 0.00 % 64 48 0 48 0.00
app/jobs/suspicious_activity_alert_job.rb 0.00 % 61 44 0 44 0.00
app/mailers/admin_mailer.rb 0.00 % 106 84 0 84 0.00
app/mailers/application_mailer.rb 0.00 % 4 4 0 4 0.00
app/mailers/passwords_mailer.rb 0.00 % 6 6 0 6 0.00
app/mailers/user_mailer.rb 0.00 % 11 10 0 10 0.00
app/models/ab_test.rb 0.00 % 319 232 0 232 0.00
app/models/ab_test_variant.rb 0.00 % 249 176 0 176 0.00
app/models/activity.rb 50.88 % 124 57 29 28 0.51
app/models/admin_audit_log.rb 0.00 % 28 24 0 24 0.00
app/models/application_record.rb 100.00 % 3 2 2 0 1.00
app/models/brand.rb 74.19 % 57 31 23 8 0.74
app/models/brand_analysis.rb 0.00 % 87 63 0 63 0.00
app/models/brand_asset.rb 65.12 % 111 43 28 15 0.65
app/models/brand_guideline.rb 73.33 % 56 30 22 8 0.73
app/models/campaign.rb 50.65 % 166 77 39 38 0.51
app/models/compliance_result.rb 0.00 % 69 51 0 51 0.00
app/models/concerns/branding/compliance/cache_invalidation.rb 53.33 % 30 15 8 7 0.60
app/models/conversion_funnel.rb 0.00 % 222 171 0 171 0.00
app/models/current.rb 0.00 % 9 8 0 8 0.00
app/models/journey.rb 38.35 % 286 133 51 82 0.38
app/models/journey_analytics.rb 40.74 % 177 81 33 48 0.41
app/models/journey_execution.rb 0.00 % 170 132 0 132 0.00
app/models/journey_insight.rb 0.00 % 404 313 0 313 0.00
app/models/journey_metric.rb 0.00 % 372 283 0 283 0.00
app/models/journey_step.rb 0.00 % 444 363 0 363 0.00
app/models/journey_template.rb 0.00 % 231 186 0 186 0.00
app/models/messaging_framework.rb 0.00 % 85 64 0 64 0.00
app/models/persona.rb 51.35 % 87 37 19 18 0.51
app/models/session.rb 0.00 % 38 25 0 25 0.00
app/models/step_execution.rb 0.00 % 80 64 0 64 0.00
app/models/step_transition.rb 0.00 % 65 54 0 54 0.00
app/models/suggestion_feedback.rb 0.00 % 112 88 0 88 0.00
app/models/user.rb 62.69 % 134 67 42 25 0.63
app/models/user_activity.rb 45.45 % 144 55 25 30 0.45
app/policies/application_policy.rb 0.00 % 53 39 0 39 0.00
app/policies/rails_admin_policy.rb 0.00 % 53 41 0 41 0.00
app/policies/user_policy.rb 0.00 % 46 32 0 32 0.00
app/services/ab_test_analytics_service.rb 0.00 % 681 539 0 539 0.00
app/services/activity_logger.rb 0.00 % 182 140 0 140 0.00
app/services/activity_report_service.rb 0.00 % 334 257 0 257 0.00
app/services/brand_journey_orchestrator.rb 0.00 % 70 55 0 55 0.00
app/services/branding/analysis_service.rb 0.00 % 2504 1940 0 1940 0.00
app/services/branding/asset_processor.rb 0.00 % 219 172 0 172 0.00
app/services/branding/compliance/base_validator.rb 0.00 % 96 83 0 83 0.00
app/services/branding/compliance/cache_service.rb 0.00 % 220 170 0 170 0.00
app/services/branding/compliance/event_broadcaster.rb 0.00 % 139 115 0 115 0.00
app/services/branding/compliance/nlp_analyzer.rb 0.00 % 932 757 0 757 0.00
app/services/branding/compliance/rule_engine.rb 0.00 % 474 374 0 374 0.00
app/services/branding/compliance/suggestion_engine.rb 0.00 % 926 742 0 742 0.00
app/services/branding/compliance/visual_validator.rb 0.00 % 723 560 0 560 0.00
app/services/branding/compliance_service.rb 0.00 % 366 296 0 296 0.00
app/services/branding/compliance_service_v2.rb 0.00 % 480 379 0 379 0.00
app/services/branding/compliance_usage_example.rb 0.00 % 234 188 0 188 0.00
app/services/campaign_analytics_service.rb 0.00 % 400 317 0 317 0.00
app/services/journey/brand_compliance_service.rb 0.00 % 536 415 0 415 0.00
app/services/journey/brand_integration_service.rb 0.00 % 915 731 0 731 0.00
app/services/journey_comparison_service.rb 0.00 % 517 412 0 412 0.00
app/services/journey_flow_engine.rb 0.00 % 209 150 0 150 0.00
app/services/journey_suggestion_engine.rb 0.00 % 812 648 0 648 0.00
app/services/llm_service.rb 0.00 % 402 338 0 338 0.00
app/services/suspicious_activity_detector.rb 0.00 % 261 204 0 204 0.00

Controllers ( 0.0% covered at 0.0 hits/line )

34 files in total.
4311 relevant lines, 0 lines covered and 4311 lines missed. ( 0.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
app/controllers/activities_controller.rb 0.00 % 37 30 0 30 0.00
app/controllers/activity_reports_controller.rb 0.00 % 79 66 0 66 0.00
app/controllers/api/v1/analytics_controller.rb 0.00 % 485 369 0 369 0.00
app/controllers/api/v1/base_controller.rb 0.00 % 49 34 0 34 0.00
app/controllers/api/v1/brand_compliance_controller.rb 0.00 % 249 210 0 210 0.00
app/controllers/api/v1/campaigns_controller.rb 0.00 % 267 213 0 213 0.00
app/controllers/api/v1/journey_steps_controller.rb 0.00 % 317 250 0 250 0.00
app/controllers/api/v1/journey_suggestions_controller.rb 0.00 % 498 433 0 433 0.00
app/controllers/api/v1/journey_templates_controller.rb 0.00 % 291 224 0 224 0.00
app/controllers/api/v1/journeys_controller.rb 0.00 % 230 193 0 193 0.00
app/controllers/api/v1/personas_controller.rb 0.00 % 471 375 0 375 0.00
app/controllers/application_controller.rb 0.00 % 19 13 0 13 0.00
app/controllers/brand_assets_controller.rb 0.00 % 169 142 0 142 0.00
app/controllers/brand_guidelines_controller.rb 0.00 % 67 55 0 55 0.00
app/controllers/brands_controller.rb 0.00 % 83 68 0 68 0.00
app/controllers/concerns/activity_trackable.rb 0.00 % 167 130 0 130 0.00
app/controllers/concerns/activity_tracker.rb 0.00 % 141 106 0 106 0.00
app/controllers/concerns/admin_auditable.rb 0.00 % 93 78 0 78 0.00
app/controllers/concerns/api_authentication.rb 0.00 % 47 36 0 36 0.00
app/controllers/concerns/api_error_handling.rb 0.00 % 61 50 0 50 0.00
app/controllers/concerns/api_pagination.rb 0.00 % 49 40 0 40 0.00
app/controllers/concerns/authentication.rb 0.00 % 95 81 0 81 0.00
app/controllers/concerns/rails_admin_auditable.rb 0.00 % 77 66 0 66 0.00
app/controllers/home_controller.rb 0.00 % 6 5 0 5 0.00
app/controllers/journey_suggestions_controller.rb 0.00 % 480 405 0 405 0.00
app/controllers/journey_templates_controller.rb 0.00 % 158 127 0 127 0.00
app/controllers/messaging_frameworks_controller.rb 0.00 % 373 306 0 306 0.00
app/controllers/passwords_controller.rb 0.00 % 43 34 0 34 0.00
app/controllers/profiles_controller.rb 0.00 % 49 39 0 39 0.00
app/controllers/rails_admin/application_controller.rb 0.00 % 27 21 0 21 0.00
app/controllers/registrations_controller.rb 0.00 % 29 22 0 22 0.00
app/controllers/sessions_controller.rb 0.00 % 66 55 0 55 0.00
app/controllers/user_sessions_controller.rb 0.00 % 26 21 0 21 0.00
app/controllers/users_controller.rb 0.00 % 18 14 0 14 0.00

Channels ( 0.0% covered at 0.0 hits/line )

2 files in total.
203 relevant lines, 0 lines covered and 203 lines missed. ( 0.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
app/channels/application_cable/connection.rb 0.00 % 16 14 0 14 0.00
app/channels/brand_compliance_channel.rb 0.00 % 250 189 0 189 0.00

Models ( 10.97% covered at 0.11 hits/line )

29 files in total.
2925 relevant lines, 321 lines covered and 2604 lines missed. ( 10.97% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
app/models/ab_test.rb 0.00 % 319 232 0 232 0.00
app/models/ab_test_variant.rb 0.00 % 249 176 0 176 0.00
app/models/activity.rb 50.88 % 124 57 29 28 0.51
app/models/admin_audit_log.rb 0.00 % 28 24 0 24 0.00
app/models/application_record.rb 100.00 % 3 2 2 0 1.00
app/models/brand.rb 74.19 % 57 31 23 8 0.74
app/models/brand_analysis.rb 0.00 % 87 63 0 63 0.00
app/models/brand_asset.rb 65.12 % 111 43 28 15 0.65
app/models/brand_guideline.rb 73.33 % 56 30 22 8 0.73
app/models/campaign.rb 50.65 % 166 77 39 38 0.51
app/models/compliance_result.rb 0.00 % 69 51 0 51 0.00
app/models/concerns/branding/compliance/cache_invalidation.rb 53.33 % 30 15 8 7 0.60
app/models/conversion_funnel.rb 0.00 % 222 171 0 171 0.00
app/models/current.rb 0.00 % 9 8 0 8 0.00
app/models/journey.rb 38.35 % 286 133 51 82 0.38
app/models/journey_analytics.rb 40.74 % 177 81 33 48 0.41
app/models/journey_execution.rb 0.00 % 170 132 0 132 0.00
app/models/journey_insight.rb 0.00 % 404 313 0 313 0.00
app/models/journey_metric.rb 0.00 % 372 283 0 283 0.00
app/models/journey_step.rb 0.00 % 444 363 0 363 0.00
app/models/journey_template.rb 0.00 % 231 186 0 186 0.00
app/models/messaging_framework.rb 0.00 % 85 64 0 64 0.00
app/models/persona.rb 51.35 % 87 37 19 18 0.51
app/models/session.rb 0.00 % 38 25 0 25 0.00
app/models/step_execution.rb 0.00 % 80 64 0 64 0.00
app/models/step_transition.rb 0.00 % 65 54 0 54 0.00
app/models/suggestion_feedback.rb 0.00 % 112 88 0 88 0.00
app/models/user.rb 62.69 % 134 67 42 25 0.63
app/models/user_activity.rb 45.45 % 144 55 25 30 0.45

Mailers ( 0.0% covered at 0.0 hits/line )

4 files in total.
104 relevant lines, 0 lines covered and 104 lines missed. ( 0.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
app/mailers/admin_mailer.rb 0.00 % 106 84 0 84 0.00
app/mailers/application_mailer.rb 0.00 % 4 4 0 4 0.00
app/mailers/passwords_mailer.rb 0.00 % 6 6 0 6 0.00
app/mailers/user_mailer.rb 0.00 % 11 10 0 10 0.00

Helpers ( 0.0% covered at 0.0 hits/line )

14 files in total.
82 relevant lines, 0 lines covered and 82 lines missed. ( 0.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
app/helpers/activities_helper.rb 0.00 % 2 2 0 2 0.00
app/helpers/api/v1/brand_compliance_helper.rb 0.00 % 2 2 0 2 0.00
app/helpers/application_helper.rb 0.00 % 2 2 0 2 0.00
app/helpers/brand_assets_helper.rb 0.00 % 2 2 0 2 0.00
app/helpers/brand_guidelines_helper.rb 0.00 % 2 2 0 2 0.00
app/helpers/brands_helper.rb 0.00 % 2 2 0 2 0.00
app/helpers/home_helper.rb 0.00 % 2 2 0 2 0.00
app/helpers/journey_templates_helper.rb 0.00 % 2 2 0 2 0.00
app/helpers/messaging_frameworks_helper.rb 0.00 % 2 2 0 2 0.00
app/helpers/profiles_helper.rb 0.00 % 2 2 0 2 0.00
app/helpers/rails_admin/dashboard_helper.rb 0.00 % 48 39 0 39 0.00
app/helpers/registrations_helper.rb 0.00 % 2 2 0 2 0.00
app/helpers/user_sessions_helper.rb 0.00 % 21 19 0 19 0.00
app/helpers/users_helper.rb 0.00 % 2 2 0 2 0.00

Jobs ( 0.0% covered at 0.0 hits/line )

9 files in total.
310 relevant lines, 0 lines covered and 310 lines missed. ( 0.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
app/jobs/activity_cleanup_job.rb 0.00 % 60 40 0 40 0.00
app/jobs/application_job.rb 0.00 % 7 2 0 2 0.00
app/jobs/brand_analysis_job.rb 0.00 % 47 31 0 31 0.00
app/jobs/brand_analysis_notification_job.rb 0.00 % 15 6 0 6 0.00
app/jobs/brand_asset_processing_job.rb 0.00 % 20 15 0 15 0.00
app/jobs/brand_compliance_job.rb 0.00 % 156 113 0 113 0.00
app/jobs/branding/compliance/cache_warmer_job.rb 0.00 % 12 11 0 11 0.00
app/jobs/journey_suggestions_cache_warmup_job.rb 0.00 % 64 48 0 48 0.00
app/jobs/suspicious_activity_alert_job.rb 0.00 % 61 44 0 44 0.00

Libraries ( 100.0% covered at 0.0 hits/line )

0 files in total.
0 relevant lines, 0 lines covered and 0 lines missed. ( 100.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line

Policies ( 0.0% covered at 0.0 hits/line )

3 files in total.
112 relevant lines, 0 lines covered and 112 lines missed. ( 0.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
app/policies/application_policy.rb 0.00 % 53 39 0 39 0.00
app/policies/rails_admin_policy.rb 0.00 % 53 41 0 41 0.00
app/policies/user_policy.rb 0.00 % 46 32 0 32 0.00

Services ( 0.0% covered at 0.0 hits/line )

24 files in total.
9982 relevant lines, 0 lines covered and 9982 lines missed. ( 0.0% )
File % covered Lines Relevant Lines Lines covered Lines missed Avg. Hits / Line
app/services/ab_test_analytics_service.rb 0.00 % 681 539 0 539 0.00
app/services/activity_logger.rb 0.00 % 182 140 0 140 0.00
app/services/activity_report_service.rb 0.00 % 334 257 0 257 0.00
app/services/brand_journey_orchestrator.rb 0.00 % 70 55 0 55 0.00
app/services/branding/analysis_service.rb 0.00 % 2504 1940 0 1940 0.00
app/services/branding/asset_processor.rb 0.00 % 219 172 0 172 0.00
app/services/branding/compliance/base_validator.rb 0.00 % 96 83 0 83 0.00
app/services/branding/compliance/cache_service.rb 0.00 % 220 170 0 170 0.00
app/services/branding/compliance/event_broadcaster.rb 0.00 % 139 115 0 115 0.00
app/services/branding/compliance/nlp_analyzer.rb 0.00 % 932 757 0 757 0.00
app/services/branding/compliance/rule_engine.rb 0.00 % 474 374 0 374 0.00
app/services/branding/compliance/suggestion_engine.rb 0.00 % 926 742 0 742 0.00
app/services/branding/compliance/visual_validator.rb 0.00 % 723 560 0 560 0.00
app/services/branding/compliance_service.rb 0.00 % 366 296 0 296 0.00
app/services/branding/compliance_service_v2.rb 0.00 % 480 379 0 379 0.00
app/services/branding/compliance_usage_example.rb 0.00 % 234 188 0 188 0.00
app/services/campaign_analytics_service.rb 0.00 % 400 317 0 317 0.00
app/services/journey/brand_compliance_service.rb 0.00 % 536 415 0 415 0.00
app/services/journey/brand_integration_service.rb 0.00 % 915 731 0 731 0.00
app/services/journey_comparison_service.rb 0.00 % 517 412 0 412 0.00
app/services/journey_flow_engine.rb 0.00 % 209 150 0 150 0.00
app/services/journey_suggestion_engine.rb 0.00 % 812 648 0 648 0.00
app/services/llm_service.rb 0.00 % 402 338 0 338 0.00
app/services/suspicious_activity_detector.rb 0.00 % 261 204 0 204 0.00

app/channels/application_cable/connection.rb

0.0% lines covered

14 relevant lines. 0 lines covered and 14 lines missed.
    
  1. module ApplicationCable
  2. class Connection < ActionCable::Connection::Base
  3. identified_by :current_user
  4. def connect
  5. set_current_user || reject_unauthorized_connection
  6. end
  7. private
  8. def set_current_user
  9. if session = Session.find_by(id: cookies.signed[:session_id])
  10. self.current_user = session.user
  11. end
  12. end
  13. end
  14. end

app/channels/brand_compliance_channel.rb

0.0% lines covered

189 relevant lines. 0 lines covered and 189 lines missed.
    
  1. class BrandComplianceChannel < ApplicationCable::Channel
  2. def subscribed
  3. if brand = find_brand
  4. # Subscribe to brand-specific compliance updates
  5. stream_from "brand_compliance_#{brand.id}"
  6. # Subscribe to session-specific updates if session_id provided
  7. if params[:session_id].present?
  8. stream_from "compliance_session_#{params[:session_id]}"
  9. end
  10. # Send initial connection confirmation
  11. transmit(
  12. event: "subscription_confirmed",
  13. brand_id: brand.id,
  14. session_id: params[:session_id]
  15. )
  16. else
  17. reject
  18. end
  19. end
  20. def unsubscribed
  21. # Cleanup any ongoing compliance checks for this session
  22. if params[:session_id].present?
  23. cancel_session_jobs(params[:session_id])
  24. end
  25. end
  26. # Client can request compliance check
  27. def check_compliance(data)
  28. brand = find_brand
  29. return unless brand && authorized_to_check?(brand)
  30. content = data["content"]
  31. content_type = data["content_type"] || "general"
  32. options = build_check_options(data)
  33. # Validate input
  34. if content.blank?
  35. transmit_error("Content cannot be blank")
  36. return
  37. end
  38. # Start compliance check
  39. if data["async"] == false
  40. # Synchronous check for small content
  41. perform_sync_check(brand, content, content_type, options)
  42. else
  43. # Asynchronous check for larger content
  44. perform_async_check(brand, content, content_type, options)
  45. end
  46. end
  47. # Client can request specific aspect validation
  48. def validate_aspect(data)
  49. brand = find_brand
  50. return unless brand && authorized_to_check?(brand)
  51. aspect = data["aspect"]&.to_sym
  52. content = data["content"]
  53. unless %i[tone sentiment readability brand_voice colors typography].include?(aspect)
  54. transmit_error("Invalid aspect: #{aspect}")
  55. return
  56. end
  57. service = Branding::ComplianceServiceV2.new(brand, content, "general")
  58. result = service.check_specific_aspects([aspect])
  59. transmit(
  60. event: "aspect_validated",
  61. aspect: aspect,
  62. result: result[aspect]
  63. )
  64. rescue StandardError => e
  65. transmit_error("Validation failed: #{e.message}")
  66. end
  67. # Client can request fix preview
  68. def preview_fix(data)
  69. brand = find_brand
  70. return unless brand && authorized_to_check?(brand)
  71. violation_id = data["violation_id"]
  72. content = data["content"]
  73. # Find the violation in the current session
  74. violation = find_session_violation(violation_id)
  75. unless violation
  76. transmit_error("Violation not found")
  77. return
  78. end
  79. suggestion_engine = Branding::Compliance::SuggestionEngine.new(brand, [violation])
  80. fix = suggestion_engine.generate_fix(violation, content)
  81. transmit(
  82. event: "fix_preview",
  83. violation_id: violation_id,
  84. fix: fix
  85. )
  86. rescue StandardError => e
  87. transmit_error("Fix generation failed: #{e.message}")
  88. end
  89. # Client can get suggestions for specific violation
  90. def get_suggestions(data)
  91. brand = find_brand
  92. return unless brand && authorized_to_check?(brand)
  93. violation_ids = Array(data["violation_ids"])
  94. violations = find_session_violations(violation_ids)
  95. suggestion_engine = Branding::Compliance::SuggestionEngine.new(brand, violations)
  96. suggestions = suggestion_engine.generate_suggestions
  97. transmit(
  98. event: "suggestions_generated",
  99. violation_ids: violation_ids,
  100. suggestions: suggestions
  101. )
  102. rescue StandardError => e
  103. transmit_error("Suggestion generation failed: #{e.message}")
  104. end
  105. private
  106. def find_brand
  107. Brand.find_by(id: params[:brand_id])
  108. end
  109. def authorized_to_check?(brand)
  110. # Check if current user has permission to check compliance for this brand
  111. return true if brand.user_id == current_user&.id
  112. # Check team permissions
  113. current_user&.has_brand_permission?(brand, :check_compliance)
  114. end
  115. def build_check_options(data)
  116. {
  117. session_id: params[:session_id],
  118. user_id: current_user&.id,
  119. broadcast_events: true,
  120. compliance_level: data["compliance_level"]&.to_sym || :standard,
  121. channel: data["channel"],
  122. audience: data["audience"],
  123. generate_suggestions: data["generate_suggestions"] != false,
  124. visual_data: data["visual_data"]
  125. }
  126. end
  127. def perform_sync_check(brand, content, content_type, options)
  128. transmit(event: "check_started", mode: "sync")
  129. service = Branding::ComplianceServiceV2.new(brand, content, content_type, options)
  130. results = service.check_compliance
  131. # Store results in session cache
  132. cache_session_results(results)
  133. transmit(
  134. event: "check_complete",
  135. results: sanitize_results(results)
  136. )
  137. rescue StandardError => e
  138. transmit_error("Compliance check failed: #{e.message}")
  139. end
  140. def perform_async_check(brand, content, content_type, options)
  141. transmit(event: "check_started", mode: "async")
  142. job = BrandComplianceJob.perform_later(
  143. brand.id,
  144. content,
  145. content_type,
  146. options.merge(
  147. broadcast_events: true,
  148. session_id: params[:session_id]
  149. )
  150. )
  151. transmit(
  152. event: "job_queued",
  153. job_id: job.job_id
  154. )
  155. rescue StandardError => e
  156. transmit_error("Failed to queue compliance check: #{e.message}")
  157. end
  158. def cache_session_results(results)
  159. return unless params[:session_id]
  160. Rails.cache.write(
  161. "compliance_session:#{params[:session_id]}:results",
  162. results,
  163. expires_in: 1.hour
  164. )
  165. end
  166. def find_session_violation(violation_id)
  167. return unless params[:session_id]
  168. results = Rails.cache.read("compliance_session:#{params[:session_id]}:results")
  169. results&.dig(:violations)&.find { |v| v[:id] == violation_id }
  170. end
  171. def find_session_violations(violation_ids)
  172. return [] unless params[:session_id]
  173. results = Rails.cache.read("compliance_session:#{params[:session_id]}:results")
  174. violations = results&.dig(:violations) || []
  175. violations.select { |v| violation_ids.include?(v[:id]) }
  176. end
  177. def cancel_session_jobs(session_id)
  178. # Implementation would depend on job tracking system
  179. # This is a placeholder for canceling any ongoing jobs
  180. end
  181. def transmit_error(message)
  182. transmit(
  183. event: "error",
  184. message: message,
  185. timestamp: Time.current.iso8601
  186. )
  187. end
  188. def sanitize_results(results)
  189. # Remove any sensitive or unnecessary data before transmitting
  190. results.slice(
  191. :compliant,
  192. :score,
  193. :summary,
  194. :violations,
  195. :suggestions,
  196. :metadata
  197. ).deep_transform_values do |value|
  198. case value
  199. when ActiveRecord::Base
  200. value.id
  201. when Time, DateTime
  202. value.iso8601
  203. else
  204. value
  205. end
  206. end
  207. end
  208. end

app/controllers/activities_controller.rb

0.0% lines covered

30 relevant lines. 0 lines covered and 30 lines missed.
    
  1. class ActivitiesController < ApplicationController
  2. def index
  3. @activities = current_user.activities
  4. .includes(:user)
  5. .recent
  6. .page(params[:page])
  7. .per(25)
  8. # Filter by date range
  9. if params[:start_date].present?
  10. @activities = @activities.where("occurred_at >= ?", params[:start_date])
  11. end
  12. if params[:end_date].present?
  13. @activities = @activities.where("occurred_at <= ?", params[:end_date])
  14. end
  15. # Filter by status
  16. case params[:status]
  17. when "suspicious"
  18. @activities = @activities.suspicious
  19. when "failed"
  20. @activities = @activities.failed_requests
  21. when "successful"
  22. @activities = @activities.successful_requests
  23. end
  24. # Activity statistics
  25. @stats = {
  26. total: current_user.activities.count,
  27. today: current_user.activities.today.count,
  28. this_week: current_user.activities.this_week.count,
  29. suspicious: current_user.activities.suspicious.count,
  30. failed_requests: current_user.activities.failed_requests.count
  31. }
  32. end
  33. end

app/controllers/activity_reports_controller.rb

0.0% lines covered

66 relevant lines. 0 lines covered and 66 lines missed.
    
  1. class ActivityReportsController < ApplicationController
  2. before_action :require_authentication
  3. def show
  4. @start_date = params[:start_date] ? Date.parse(params[:start_date]) : 30.days.ago
  5. @end_date = params[:end_date] ? Date.parse(params[:end_date]) : Date.current
  6. @report = ActivityReportService.new(
  7. current_user,
  8. start_date: @start_date,
  9. end_date: @end_date
  10. ).generate_report
  11. respond_to do |format|
  12. format.html
  13. format.json { render json: @report }
  14. format.pdf { render_pdf } if defined?(Prawn)
  15. end
  16. end
  17. def export
  18. @start_date = params[:start_date] ? Date.parse(params[:start_date]) : 30.days.ago
  19. @end_date = params[:end_date] ? Date.parse(params[:end_date]) : Date.current
  20. activities = current_user.activities
  21. .where(occurred_at: @start_date.beginning_of_day..@end_date.end_of_day)
  22. .order(:occurred_at)
  23. respond_to do |format|
  24. format.csv { send_data generate_csv(activities), filename: "activity_report_#{Date.current}.csv" }
  25. end
  26. end
  27. private
  28. def generate_csv(activities)
  29. require 'csv'
  30. CSV.generate(headers: true) do |csv|
  31. csv << [
  32. 'Date/Time',
  33. 'Action',
  34. 'Path',
  35. 'Method',
  36. 'Status',
  37. 'Response Time (ms)',
  38. 'IP Address',
  39. 'Device',
  40. 'Browser',
  41. 'OS',
  42. 'Suspicious',
  43. 'Reasons'
  44. ]
  45. activities.find_each do |activity|
  46. csv << [
  47. activity.occurred_at.strftime('%Y-%m-%d %H:%M:%S'),
  48. activity.full_action,
  49. activity.request_path,
  50. activity.request_method,
  51. activity.response_status,
  52. activity.duration_in_ms,
  53. activity.ip_address,
  54. activity.device_type,
  55. activity.browser_name,
  56. activity.os_name,
  57. activity.suspicious? ? 'Yes' : 'No',
  58. activity.metadata['suspicious_reasons']&.join(', ')
  59. ]
  60. end
  61. end
  62. end
  63. def render_pdf
  64. # This would require the Prawn gem
  65. # Implementation depends on specific PDF requirements
  66. render plain: "PDF export not implemented", status: :not_implemented
  67. end
  68. end

app/controllers/api/v1/analytics_controller.rb

0.0% lines covered

369 relevant lines. 0 lines covered and 369 lines missed.
    
  1. class Api::V1::AnalyticsController < Api::V1::BaseController
  2. # GET /api/v1/analytics/overview
  3. def overview
  4. days = [params[:days].to_i, 7].max
  5. days = [days, 365].min # Cap at 1 year
  6. overview_data = {
  7. summary: calculate_user_overview(days),
  8. journeys: calculate_journey_overview(days),
  9. campaigns: calculate_campaign_overview(days),
  10. performance: calculate_performance_overview(days)
  11. }
  12. render_success(data: overview_data)
  13. end
  14. # GET /api/v1/analytics/journeys/:id
  15. def journey_analytics
  16. journey = current_user.journeys.find(params[:id])
  17. days = [params[:days].to_i, 30].max
  18. days = [days, 365].min
  19. analytics_data = {
  20. summary: journey.analytics_summary(days),
  21. performance_score: journey.latest_performance_score,
  22. funnel_performance: journey.funnel_performance('default', days),
  23. trends: journey.performance_trends(7),
  24. ab_test_status: journey.ab_test_status,
  25. step_analytics: calculate_step_analytics(journey, days),
  26. conversion_metrics: calculate_journey_conversions(journey, days),
  27. engagement_metrics: calculate_journey_engagement(journey, days)
  28. }
  29. render_success(data: analytics_data)
  30. end
  31. # GET /api/v1/analytics/campaigns/:id
  32. def campaign_analytics
  33. campaign = current_user.campaigns.find(params[:id])
  34. days = [params[:days].to_i, 30].max
  35. days = [days, 365].min
  36. analytics_service = CampaignAnalyticsService.new(campaign)
  37. analytics_data = analytics_service.generate_report(days)
  38. render_success(data: analytics_data)
  39. end
  40. # GET /api/v1/analytics/funnels/:journey_id
  41. def funnel_analytics
  42. journey = current_user.journeys.find(params[:journey_id])
  43. funnel_name = params[:funnel_name] || 'default'
  44. days = [params[:days].to_i, 7].max
  45. days = [days, 90].min
  46. start_date = days.days.ago
  47. end_date = Time.current
  48. funnel_data = {
  49. overview: ConversionFunnel.funnel_overview(journey.id, funnel_name, start_date, end_date),
  50. steps: ConversionFunnel.funnel_step_breakdown(journey.id, funnel_name, start_date, end_date),
  51. trends: ConversionFunnel.funnel_trends(journey.id, funnel_name, start_date, end_date),
  52. drop_off_analysis: calculate_drop_off_analysis(journey, funnel_name, start_date, end_date)
  53. }
  54. render_success(data: funnel_data)
  55. end
  56. # GET /api/v1/analytics/ab_tests/:id
  57. def ab_test_analytics
  58. ab_test = current_user.ab_tests.find(params[:id])
  59. days = [params[:days].to_i, ab_test.duration_days].max
  60. ab_analytics_service = AbTestAnalyticsService.new(ab_test)
  61. analytics_data = ab_analytics_service.generate_report(days)
  62. render_success(data: analytics_data)
  63. end
  64. # GET /api/v1/analytics/comparative
  65. def comparative_analytics
  66. journey_ids = params[:journey_ids].to_s.split(',').map(&:to_i)
  67. if journey_ids.empty? || journey_ids.count > 5
  68. return render_error(message: 'Please provide 1-5 journey IDs for comparison')
  69. end
  70. journeys = current_user.journeys.where(id: journey_ids)
  71. unless journeys.count == journey_ids.count
  72. return render_error(message: 'One or more journeys not found')
  73. end
  74. days = [params[:days].to_i, 30].max
  75. days = [days, 90].min
  76. comparison_service = JourneyComparisonService.new(journeys)
  77. comparison_data = comparison_service.generate_comparison(days)
  78. render_success(data: comparison_data)
  79. end
  80. # GET /api/v1/analytics/trends
  81. def trends
  82. days = [params[:days].to_i, 30].max
  83. days = [days, 365].min
  84. metric = params[:metric] || 'conversion_rate'
  85. unless %w[conversion_rate engagement_score completion_rate execution_count].include?(metric)
  86. return render_error(message: 'Invalid metric specified')
  87. end
  88. trends_data = calculate_user_trends(metric, days)
  89. render_success(data: trends_data)
  90. end
  91. # GET /api/v1/analytics/personas/:id/performance
  92. def persona_performance
  93. persona = current_user.personas.find(params[:id])
  94. days = [params[:days].to_i, 30].max
  95. days = [days, 365].min
  96. # Get campaigns and journeys associated with this persona
  97. campaigns = persona.campaigns.includes(:journeys)
  98. journeys = campaigns.flat_map(&:journeys)
  99. performance_data = {
  100. summary: calculate_persona_summary(persona, journeys, days),
  101. campaign_performance: calculate_persona_campaign_performance(campaigns, days),
  102. journey_performance: calculate_persona_journey_performance(journeys, days),
  103. engagement_patterns: calculate_persona_engagement_patterns(persona, days),
  104. conversion_insights: calculate_persona_conversion_insights(persona, days)
  105. }
  106. render_success(data: performance_data)
  107. end
  108. # POST /api/v1/analytics/custom_report
  109. def custom_report
  110. report_params = params.permit(
  111. :name, :description, :date_range_days,
  112. metrics: [], filters: {}, grouping: []
  113. )
  114. begin
  115. # Generate custom analytics report based on parameters
  116. report_data = generate_custom_report(report_params)
  117. render_success(
  118. data: report_data,
  119. message: 'Custom report generated successfully'
  120. )
  121. rescue => e
  122. render_error(message: "Failed to generate report: #{e.message}")
  123. end
  124. end
  125. # GET /api/v1/analytics/real_time
  126. def real_time
  127. # Get real-time metrics for the last 24 hours
  128. real_time_data = {
  129. active_journeys: calculate_active_journeys,
  130. recent_executions: calculate_recent_executions,
  131. live_conversions: calculate_live_conversions,
  132. engagement_activity: calculate_engagement_activity,
  133. system_health: calculate_system_health
  134. }
  135. render_success(data: real_time_data)
  136. end
  137. private
  138. def calculate_user_overview(days)
  139. journeys = current_user.journeys
  140. start_date = days.days.ago
  141. {
  142. total_journeys: journeys.count,
  143. active_journeys: journeys.where(status: %w[draft published]).count,
  144. total_executions: current_user.journey_executions.where(created_at: start_date..).count,
  145. total_campaigns: current_user.campaigns.count,
  146. total_personas: current_user.personas.count,
  147. period_days: days
  148. }
  149. end
  150. def calculate_journey_overview(days)
  151. journeys = current_user.journeys.includes(:journey_analytics)
  152. start_date = days.days.ago
  153. analytics = JourneyAnalytics.joins(:journey)
  154. .where(journeys: { user: current_user })
  155. .where(period_start: start_date..)
  156. {
  157. average_conversion_rate: analytics.average(:conversion_rate)&.round(2) || 0,
  158. average_engagement_score: analytics.average(:engagement_score)&.round(2) || 0,
  159. total_executions: analytics.sum(:total_executions),
  160. completed_executions: analytics.sum(:completed_executions),
  161. top_performing: find_top_performing_journeys(5)
  162. }
  163. end
  164. def calculate_campaign_overview(days)
  165. campaigns = current_user.campaigns.includes(:journeys)
  166. {
  167. active_campaigns: campaigns.where(status: 'active').count,
  168. total_journey_count: campaigns.joins(:journeys).count,
  169. campaign_performance: campaigns.limit(5).map do |campaign|
  170. {
  171. id: campaign.id,
  172. name: campaign.name,
  173. journey_count: campaign.journeys.count,
  174. status: campaign.status
  175. }
  176. end
  177. }
  178. end
  179. def calculate_performance_overview(days)
  180. start_date = days.days.ago
  181. # Get performance metrics across all user's journeys
  182. user_journey_ids = current_user.journeys.pluck(:id)
  183. metrics = JourneyMetrics.where(journey_id: user_journey_ids)
  184. .where(period_start: start_date..)
  185. {
  186. average_performance_score: calculate_average_performance_score(metrics),
  187. trend_direction: calculate_trend_direction(metrics),
  188. key_insights: generate_key_insights(metrics)
  189. }
  190. end
  191. def calculate_step_analytics(journey, days)
  192. journey.journey_steps.includes(:step_executions).map do |step|
  193. executions = step.step_executions.where(created_at: days.days.ago..)
  194. {
  195. step_id: step.id,
  196. step_name: step.name,
  197. step_type: step.step_type,
  198. execution_count: executions.count,
  199. completion_rate: calculate_step_completion_rate(executions),
  200. average_duration: calculate_average_duration(executions)
  201. }
  202. end
  203. end
  204. def calculate_journey_conversions(journey, days)
  205. # Placeholder for detailed conversion calculations
  206. {
  207. total_conversions: 0,
  208. conversion_rate: 0.0,
  209. conversion_value: 0.0,
  210. conversion_by_source: {},
  211. conversion_trends: []
  212. }
  213. end
  214. def calculate_journey_engagement(journey, days)
  215. # Placeholder for engagement calculations
  216. {
  217. engagement_score: 0.0,
  218. interaction_count: 0,
  219. average_session_duration: 0.0,
  220. bounce_rate: 0.0,
  221. engagement_by_step: []
  222. }
  223. end
  224. def calculate_drop_off_analysis(journey, funnel_name, start_date, end_date)
  225. # Analyze where users drop off in the funnel
  226. steps = journey.journey_steps.order(:position)
  227. drop_off_data = []
  228. steps.each_with_index do |step, index|
  229. next_step = steps[index + 1]
  230. next unless next_step
  231. # Calculate drop-off rate between this step and the next
  232. current_executions = step.step_executions.where(created_at: start_date..end_date).count
  233. next_executions = next_step.step_executions.where(created_at: start_date..end_date).count
  234. drop_off_rate = current_executions > 0 ? ((current_executions - next_executions).to_f / current_executions * 100).round(2) : 0
  235. drop_off_data << {
  236. from_step: step.name,
  237. to_step: next_step.name,
  238. drop_off_rate: drop_off_rate,
  239. users_lost: current_executions - next_executions
  240. }
  241. end
  242. drop_off_data
  243. end
  244. def find_top_performing_journeys(limit)
  245. current_user.journeys
  246. .joins(:journey_analytics)
  247. .group('journeys.id, journeys.name')
  248. .order('AVG(journey_analytics.conversion_rate) DESC')
  249. .limit(limit)
  250. .pluck('journeys.id, journeys.name, AVG(journey_analytics.conversion_rate)')
  251. .map { |id, name, rate| { id: id, name: name, conversion_rate: rate.round(2) } }
  252. end
  253. def calculate_average_performance_score(metrics)
  254. return 0.0 if metrics.empty?
  255. # Calculate weighted performance score across all metrics
  256. total_score = metrics.sum do |metric|
  257. conversion_weight = 0.4
  258. engagement_weight = 0.3
  259. completion_weight = 0.3
  260. (metric.conversion_rate * conversion_weight +
  261. metric.engagement_score * engagement_weight +
  262. metric.completion_rate * completion_weight)
  263. end
  264. (total_score / metrics.count).round(1)
  265. end
  266. def calculate_trend_direction(metrics)
  267. return 'stable' if metrics.count < 2
  268. recent_scores = metrics.order(:period_start).last(7).map(&:conversion_rate)
  269. return 'stable' if recent_scores.count < 2
  270. trend = (recent_scores.last - recent_scores.first) / recent_scores.first
  271. if trend > 0.05
  272. 'improving'
  273. elsif trend < -0.05
  274. 'declining'
  275. else
  276. 'stable'
  277. end
  278. end
  279. def generate_key_insights(metrics)
  280. insights = []
  281. # Add performance insights based on metrics analysis
  282. if metrics.any?
  283. avg_conversion = metrics.average(:conversion_rate)
  284. if avg_conversion > 10
  285. insights << "Strong conversion performance across journeys"
  286. elsif avg_conversion < 2
  287. insights << "Conversion rates could be improved"
  288. end
  289. high_engagement = metrics.where('engagement_score > ?', 75).count
  290. if high_engagement > metrics.count * 0.7
  291. insights << "High engagement levels maintained"
  292. end
  293. end
  294. insights
  295. end
  296. def calculate_user_trends(metric, days)
  297. # Calculate trends for specified metric over time
  298. user_journey_ids = current_user.journeys.pluck(:id)
  299. analytics = JourneyAnalytics.where(journey_id: user_journey_ids)
  300. .where(period_start: days.days.ago..)
  301. .order(:period_start)
  302. trends = analytics.group_by_day(:period_start).average(metric)
  303. {
  304. metric: metric,
  305. period_days: days,
  306. data_points: trends.map { |date, value| { date: date, value: value&.round(2) || 0 } }
  307. }
  308. end
  309. def calculate_persona_summary(persona, journeys, days)
  310. {
  311. persona_name: persona.name,
  312. total_journeys: journeys.count,
  313. total_campaigns: persona.campaigns.count,
  314. performance_score: calculate_persona_performance_score(journeys, days)
  315. }
  316. end
  317. def calculate_persona_campaign_performance(campaigns, days)
  318. campaigns.map do |campaign|
  319. {
  320. id: campaign.id,
  321. name: campaign.name,
  322. status: campaign.status,
  323. journey_count: campaign.journeys.count
  324. }
  325. end
  326. end
  327. def calculate_persona_journey_performance(journeys, days)
  328. journeys.map do |journey|
  329. {
  330. id: journey.id,
  331. name: journey.name,
  332. performance_score: journey.latest_performance_score,
  333. conversion_rate: journey.current_analytics&.conversion_rate || 0
  334. }
  335. end
  336. end
  337. def calculate_persona_engagement_patterns(persona, days)
  338. # Placeholder for persona engagement analysis
  339. {
  340. preferred_channels: [],
  341. engagement_times: [],
  342. content_preferences: []
  343. }
  344. end
  345. def calculate_persona_conversion_insights(persona, days)
  346. # Placeholder for persona conversion analysis
  347. {
  348. conversion_triggers: [],
  349. optimal_journey_length: 0,
  350. successful_touchpoints: []
  351. }
  352. end
  353. def calculate_persona_performance_score(journeys, days)
  354. return 0.0 if journeys.empty?
  355. scores = journeys.map(&:latest_performance_score).compact
  356. return 0.0 if scores.empty?
  357. (scores.sum.to_f / scores.count).round(1)
  358. end
  359. def generate_custom_report(report_params)
  360. # Placeholder for custom report generation
  361. {
  362. report_name: report_params[:name],
  363. generated_at: Time.current,
  364. data: {
  365. summary: "Custom report functionality would be implemented here",
  366. metrics: report_params[:metrics] || [],
  367. filters_applied: report_params[:filters] || {}
  368. }
  369. }
  370. end
  371. def calculate_active_journeys
  372. current_user.journeys.where(status: %w[draft published]).count
  373. end
  374. def calculate_recent_executions
  375. current_user.journey_executions.where(created_at: 24.hours.ago..).count
  376. end
  377. def calculate_live_conversions
  378. # Placeholder for real-time conversion tracking
  379. 0
  380. end
  381. def calculate_engagement_activity
  382. # Placeholder for real-time engagement tracking
  383. {
  384. active_sessions: 0,
  385. recent_interactions: 0
  386. }
  387. end
  388. def calculate_system_health
  389. {
  390. status: 'healthy',
  391. response_time: 'normal',
  392. uptime: '99.9%'
  393. }
  394. end
  395. end

app/controllers/api/v1/base_controller.rb

0.0% lines covered

34 relevant lines. 0 lines covered and 34 lines missed.
    
  1. class Api::V1::BaseController < ApplicationController
  2. # Skip CSRF protection for API endpoints
  3. skip_before_action :verify_authenticity_token
  4. # Use JSON format by default
  5. before_action :set_default_format
  6. # Include API-specific concerns
  7. include ApiAuthentication
  8. include ApiErrorHandling
  9. include ApiPagination
  10. private
  11. def set_default_format
  12. request.format = :json unless params[:format]
  13. end
  14. # API-specific success response format
  15. def render_success(data: nil, message: nil, status: :ok, meta: {})
  16. response_body = { success: true }
  17. response_body[:data] = data if data
  18. response_body[:message] = message if message
  19. response_body[:meta] = meta if meta.any?
  20. render json: response_body, status: status
  21. end
  22. # API-specific error response format
  23. def render_error(message: nil, errors: {}, status: :unprocessable_entity, code: nil)
  24. response_body = {
  25. success: false,
  26. message: message || 'An error occurred'
  27. }
  28. response_body[:code] = code if code
  29. response_body[:errors] = errors if errors.any?
  30. render json: response_body, status: status
  31. end
  32. # Ensure user can only access their own resources
  33. def ensure_user_resource_access(resource)
  34. unless resource&.user == current_user
  35. render_error(message: 'Resource not found', status: :not_found)
  36. return false
  37. end
  38. true
  39. end
  40. end

app/controllers/api/v1/brand_compliance_controller.rb

0.0% lines covered

210 relevant lines. 0 lines covered and 210 lines missed.
    
  1. module Api
  2. module V1
  3. class BrandComplianceController < ApplicationController
  4. before_action :authenticate_user!
  5. before_action :set_brand
  6. before_action :authorize_brand_access
  7. # POST /api/v1/brands/:brand_id/compliance/check
  8. def check
  9. content = compliance_params[:content]
  10. content_type = compliance_params[:content_type] || "general"
  11. if content.blank?
  12. render json: { error: "Content is required" }, status: :unprocessable_entity
  13. return
  14. end
  15. options = build_compliance_options
  16. # Use async processing for large content
  17. if content.length > 10_000 && params[:sync] != "true"
  18. job = BrandComplianceJob.perform_later(
  19. @brand.id,
  20. content,
  21. content_type,
  22. options.merge(
  23. user_id: current_user.id,
  24. notify: params[:notify] == "true",
  25. store_results: true
  26. )
  27. )
  28. render json: {
  29. status: "processing",
  30. job_id: job.job_id,
  31. message: "Compliance check queued for processing"
  32. }, status: :accepted
  33. else
  34. service = Branding::ComplianceServiceV2.new(@brand, content, content_type, options)
  35. results = service.check_compliance
  36. # Store results if requested
  37. store_results(results) if params[:store_results] == "true"
  38. render json: format_compliance_results(results)
  39. end
  40. rescue StandardError => e
  41. render json: { error: e.message }, status: :internal_server_error
  42. end
  43. # POST /api/v1/brands/:brand_id/compliance/validate_aspect
  44. def validate_aspect
  45. aspect = params[:aspect]&.to_sym
  46. content = compliance_params[:content]
  47. unless %i[tone sentiment readability brand_voice colors typography logo composition].include?(aspect)
  48. render json: { error: "Invalid aspect: #{aspect}" }, status: :unprocessable_entity
  49. return
  50. end
  51. service = Branding::ComplianceServiceV2.new(@brand, content, "general", build_compliance_options)
  52. results = service.check_specific_aspects([aspect])
  53. render json: {
  54. aspect: aspect,
  55. results: results[aspect],
  56. timestamp: Time.current
  57. }
  58. rescue StandardError => e
  59. render json: { error: e.message }, status: :internal_server_error
  60. end
  61. # POST /api/v1/brands/:brand_id/compliance/preview_fix
  62. def preview_fix
  63. violation = params[:violation]
  64. content = compliance_params[:content]
  65. unless violation.present?
  66. render json: { error: "Violation data is required" }, status: :unprocessable_entity
  67. return
  68. end
  69. suggestion_engine = Branding::Compliance::SuggestionEngine.new(@brand, [violation])
  70. fix = suggestion_engine.generate_fix(violation, content)
  71. render json: {
  72. violation_id: violation[:id],
  73. fix: fix,
  74. alternatives: suggestion_engine.suggest_alternatives(
  75. content[0..100],
  76. { content_type: params[:content_type], audience: params[:audience] }
  77. )
  78. }
  79. rescue StandardError => e
  80. render json: { error: e.message }, status: :internal_server_error
  81. end
  82. # GET /api/v1/brands/:brand_id/compliance/history
  83. def history
  84. results = @brand.compliance_results
  85. .by_content_type(params[:content_type])
  86. .recent
  87. .page(params[:page])
  88. .per(params[:per_page] || 20)
  89. render json: {
  90. results: results.map { |r| format_history_result(r) },
  91. pagination: {
  92. current_page: results.current_page,
  93. total_pages: results.total_pages,
  94. total_count: results.total_count
  95. },
  96. statistics: {
  97. average_score: results.average_score,
  98. compliance_rate: results.compliance_rate,
  99. common_violations: @brand.compliance_results.common_violations(5)
  100. }
  101. }
  102. end
  103. # POST /api/v1/brands/:brand_id/compliance/validate_and_fix
  104. def validate_and_fix
  105. content = compliance_params[:content]
  106. content_type = compliance_params[:content_type] || "general"
  107. service = Branding::ComplianceServiceV2.new(@brand, content, content_type, build_compliance_options)
  108. results = service.validate_and_fix
  109. render json: {
  110. original_compliant: results[:original_results][:compliant],
  111. original_score: results[:original_results][:score],
  112. fixes_applied: results[:fixes_applied],
  113. final_compliant: results[:final_results][:compliant],
  114. final_score: results[:final_results][:score],
  115. fixed_content: results[:fixed_content]
  116. }
  117. rescue StandardError => e
  118. render json: { error: e.message }, status: :internal_server_error
  119. end
  120. private
  121. def set_brand
  122. @brand = Brand.find(params[:brand_id])
  123. rescue ActiveRecord::RecordNotFound
  124. render json: { error: "Brand not found" }, status: :not_found
  125. end
  126. def authorize_brand_access
  127. unless @brand.user_id == current_user.id || current_user.has_brand_permission?(@brand, :check_compliance)
  128. render json: { error: "Unauthorized" }, status: :forbidden
  129. end
  130. end
  131. def compliance_params
  132. params.permit(:content, :content_type, :visual_data => {})
  133. end
  134. def build_compliance_options
  135. {
  136. compliance_level: (params[:compliance_level] || "standard").to_sym,
  137. generate_suggestions: params[:suggestions] != "false",
  138. channel: params[:channel],
  139. audience: params[:audience],
  140. cache_results: params[:cache] != "false",
  141. visual_data: params[:visual_data]
  142. }
  143. end
  144. def store_results(results)
  145. ComplianceResult.create!(
  146. brand: @brand,
  147. content_type: params[:content_type] || "general",
  148. content_hash: Digest::SHA256.hexdigest(compliance_params[:content]),
  149. compliant: results[:compliant],
  150. score: results[:score],
  151. violations_count: results[:violations]&.count || 0,
  152. violations_data: results[:violations] || [],
  153. suggestions_data: results[:suggestions] || [],
  154. analysis_data: results[:analysis] || {},
  155. metadata: results[:metadata] || {}
  156. )
  157. rescue StandardError => e
  158. Rails.logger.error "Failed to store compliance results: #{e.message}"
  159. end
  160. def format_compliance_results(results)
  161. {
  162. compliant: results[:compliant],
  163. score: results[:score],
  164. summary: results[:summary],
  165. violations: format_violations(results[:violations]),
  166. suggestions: format_suggestions(results[:suggestions]),
  167. metadata: {
  168. processing_time: results[:metadata][:processing_time],
  169. validators_used: results[:metadata][:validators_used],
  170. compliance_level: results[:metadata][:compliance_level],
  171. timestamp: Time.current
  172. }
  173. }
  174. end
  175. def format_violations(violations)
  176. return [] unless violations
  177. violations.map do |violation|
  178. {
  179. id: violation[:id],
  180. type: violation[:type],
  181. severity: violation[:severity],
  182. message: violation[:message],
  183. validator: violation[:validator_type],
  184. position: violation[:position],
  185. details: violation[:details]
  186. }
  187. end
  188. end
  189. def format_suggestions(suggestions)
  190. return [] unless suggestions
  191. suggestions.map do |suggestion|
  192. {
  193. type: suggestion[:type],
  194. priority: suggestion[:priority],
  195. title: suggestion[:title],
  196. description: suggestion[:description],
  197. actions: suggestion[:specific_actions],
  198. effort: suggestion[:effort_level],
  199. estimated_time: suggestion[:estimated_time]
  200. }
  201. end
  202. end
  203. def format_history_result(result)
  204. {
  205. id: result.id,
  206. content_type: result.content_type,
  207. compliant: result.compliant,
  208. score: result.score,
  209. violations_count: result.violations_count,
  210. high_severity_count: result.high_severity_violations.count,
  211. created_at: result.created_at,
  212. processing_time: result.processing_time_seconds
  213. }
  214. end
  215. end
  216. end
  217. end

app/controllers/api/v1/campaigns_controller.rb

0.0% lines covered

213 relevant lines. 0 lines covered and 213 lines missed.
    
  1. class Api::V1::CampaignsController < Api::V1::BaseController
  2. before_action :set_campaign, only: [:show, :update, :destroy, :activate, :pause, :analytics]
  3. # GET /api/v1/campaigns
  4. def index
  5. campaigns = current_user.campaigns.includes(:persona, :journeys)
  6. # Apply filters
  7. campaigns = campaigns.where(status: params[:status]) if params[:status].present?
  8. campaigns = campaigns.where(campaign_type: params[:campaign_type]) if params[:campaign_type].present?
  9. campaigns = campaigns.where(industry: params[:industry]) if params[:industry].present?
  10. campaigns = campaigns.where(persona_id: params[:persona_id]) if params[:persona_id].present?
  11. # Apply search
  12. if params[:search].present?
  13. campaigns = campaigns.where(
  14. 'name ILIKE ? OR description ILIKE ?',
  15. "%#{params[:search]}%", "%#{params[:search]}%"
  16. )
  17. end
  18. # Apply sorting
  19. case params[:sort_by]
  20. when 'name'
  21. campaigns = campaigns.order(:name)
  22. when 'status'
  23. campaigns = campaigns.order(:status, :name)
  24. when 'created_at'
  25. campaigns = campaigns.order(:created_at)
  26. when 'updated_at'
  27. campaigns = campaigns.order(:updated_at)
  28. else
  29. campaigns = campaigns.order(updated_at: :desc)
  30. end
  31. paginate_and_render(campaigns, serializer: method(:serialize_campaign_summary))
  32. end
  33. # GET /api/v1/campaigns/:id
  34. def show
  35. render_success(data: serialize_campaign_detail(@campaign))
  36. end
  37. # POST /api/v1/campaigns
  38. def create
  39. campaign = current_user.campaigns.build(campaign_params)
  40. if campaign.save
  41. render_success(
  42. data: serialize_campaign_detail(campaign),
  43. message: 'Campaign created successfully',
  44. status: :created
  45. )
  46. else
  47. render_error(
  48. message: 'Failed to create campaign',
  49. errors: campaign.errors.as_json
  50. )
  51. end
  52. end
  53. # PUT /api/v1/campaigns/:id
  54. def update
  55. if @campaign.update(campaign_params)
  56. render_success(
  57. data: serialize_campaign_detail(@campaign),
  58. message: 'Campaign updated successfully'
  59. )
  60. else
  61. render_error(
  62. message: 'Failed to update campaign',
  63. errors: @campaign.errors.as_json
  64. )
  65. end
  66. end
  67. # DELETE /api/v1/campaigns/:id
  68. def destroy
  69. @campaign.destroy!
  70. render_success(message: 'Campaign deleted successfully')
  71. end
  72. # POST /api/v1/campaigns/:id/activate
  73. def activate
  74. if @campaign.activate!
  75. render_success(
  76. data: serialize_campaign_detail(@campaign),
  77. message: 'Campaign activated successfully'
  78. )
  79. else
  80. render_error(
  81. message: 'Failed to activate campaign',
  82. errors: @campaign.errors.as_json
  83. )
  84. end
  85. end
  86. # POST /api/v1/campaigns/:id/pause
  87. def pause
  88. if @campaign.pause!
  89. render_success(
  90. data: serialize_campaign_detail(@campaign),
  91. message: 'Campaign paused successfully'
  92. )
  93. else
  94. render_error(
  95. message: 'Failed to pause campaign',
  96. errors: @campaign.errors.as_json
  97. )
  98. end
  99. end
  100. # GET /api/v1/campaigns/:id/analytics
  101. def analytics
  102. days = [params[:days].to_i, 30].max
  103. days = [days, 365].min
  104. analytics_service = CampaignAnalyticsService.new(@campaign)
  105. analytics_data = analytics_service.generate_report(days)
  106. render_success(data: analytics_data)
  107. end
  108. # GET /api/v1/campaigns/:id/journeys
  109. def journeys
  110. journeys = @campaign.journeys.includes(:journey_steps, :journey_analytics)
  111. # Apply filters
  112. journeys = journeys.where(status: params[:status]) if params[:status].present?
  113. # Apply sorting
  114. case params[:sort_by]
  115. when 'name'
  116. journeys = journeys.order(:name)
  117. when 'performance'
  118. # Sort by latest performance score
  119. journeys = journeys.joins(:journey_analytics)
  120. .group('journeys.id')
  121. .order('AVG(journey_analytics.conversion_rate) DESC')
  122. else
  123. journeys = journeys.order(created_at: :desc)
  124. end
  125. paginate_and_render(journeys, serializer: method(:serialize_journey_for_campaign))
  126. end
  127. # POST /api/v1/campaigns/:id/journeys
  128. def add_journey
  129. journey_params = params.require(:journey).permit(:id, :name, :description)
  130. if journey_params[:id].present?
  131. # Associate existing journey
  132. journey = current_user.journeys.find(journey_params[:id])
  133. journey.update!(campaign: @campaign)
  134. else
  135. # Create new journey for campaign
  136. journey = @campaign.journeys.build(
  137. journey_params.merge(user: current_user)
  138. )
  139. journey.save!
  140. end
  141. render_success(
  142. data: serialize_journey_for_campaign(journey),
  143. message: 'Journey added to campaign successfully',
  144. status: :created
  145. )
  146. end
  147. # DELETE /api/v1/campaigns/:id/journeys/:journey_id
  148. def remove_journey
  149. journey = @campaign.journeys.find(params[:journey_id])
  150. journey.update!(campaign: nil)
  151. render_success(message: 'Journey removed from campaign successfully')
  152. end
  153. # GET /api/v1/campaigns/industries
  154. def industries
  155. industries = Campaign.where(user: current_user).distinct.pluck(:industry).compact.sort
  156. render_success(data: industries)
  157. end
  158. # GET /api/v1/campaigns/types
  159. def types
  160. types = Campaign::CAMPAIGN_TYPES
  161. render_success(data: types)
  162. end
  163. private
  164. def set_campaign
  165. @campaign = current_user.campaigns.find(params[:id])
  166. end
  167. def campaign_params
  168. params.require(:campaign).permit(
  169. :name, :description, :campaign_type, :industry, :status,
  170. :start_date, :end_date, :budget, :persona_id,
  171. goals: [], target_metrics: {}, settings: {}
  172. )
  173. end
  174. def serialize_campaign_summary(campaign)
  175. {
  176. id: campaign.id,
  177. name: campaign.name,
  178. description: campaign.description,
  179. campaign_type: campaign.campaign_type,
  180. industry: campaign.industry,
  181. status: campaign.status,
  182. persona_id: campaign.persona_id,
  183. persona_name: campaign.persona&.name,
  184. journey_count: campaign.journeys.count,
  185. start_date: campaign.start_date,
  186. end_date: campaign.end_date,
  187. budget: campaign.budget,
  188. created_at: campaign.created_at,
  189. updated_at: campaign.updated_at
  190. }
  191. end
  192. def serialize_campaign_detail(campaign)
  193. {
  194. id: campaign.id,
  195. name: campaign.name,
  196. description: campaign.description,
  197. campaign_type: campaign.campaign_type,
  198. industry: campaign.industry,
  199. status: campaign.status,
  200. start_date: campaign.start_date,
  201. end_date: campaign.end_date,
  202. budget: campaign.budget,
  203. goals: campaign.goals,
  204. target_metrics: campaign.target_metrics,
  205. settings: campaign.settings,
  206. persona: campaign.persona ? serialize_persona_for_campaign(campaign.persona) : nil,
  207. journey_count: campaign.journeys.count,
  208. created_at: campaign.created_at,
  209. updated_at: campaign.updated_at
  210. }
  211. end
  212. def serialize_persona_for_campaign(persona)
  213. {
  214. id: persona.id,
  215. name: persona.name,
  216. age_range: persona.age_range,
  217. location: persona.location,
  218. demographic_data: persona.demographic_data,
  219. psychographic_data: persona.psychographic_data
  220. }
  221. end
  222. def serialize_journey_for_campaign(journey)
  223. {
  224. id: journey.id,
  225. name: journey.name,
  226. description: journey.description,
  227. status: journey.status,
  228. step_count: journey.total_steps,
  229. performance_score: journey.latest_performance_score,
  230. created_at: journey.created_at,
  231. updated_at: journey.updated_at
  232. }
  233. end
  234. end

app/controllers/api/v1/journey_steps_controller.rb

0.0% lines covered

250 relevant lines. 0 lines covered and 250 lines missed.
    
  1. class Api::V1::JourneyStepsController < Api::V1::BaseController
  2. before_action :set_journey
  3. before_action :set_step, only: [:show, :update, :destroy, :reorder, :duplicate, :execute]
  4. # GET /api/v1/journeys/:journey_id/steps
  5. def index
  6. steps = @journey.journey_steps.includes(:transitions_from, :transitions_to)
  7. # Apply filters
  8. steps = steps.where(stage: params[:stage]) if params[:stage].present?
  9. steps = steps.where(step_type: params[:step_type]) if params[:step_type].present?
  10. steps = steps.where(status: params[:status]) if params[:status].present?
  11. # Apply sorting
  12. case params[:sort_by]
  13. when 'position'
  14. steps = steps.order(:position)
  15. when 'stage'
  16. steps = steps.order(:stage, :position)
  17. when 'created_at'
  18. steps = steps.order(:created_at)
  19. else
  20. steps = steps.order(:position)
  21. end
  22. paginate_and_render(steps, serializer: method(:serialize_step_summary))
  23. end
  24. # GET /api/v1/journeys/:journey_id/steps/:id
  25. def show
  26. render_success(data: serialize_step_detail(@step))
  27. end
  28. # POST /api/v1/journeys/:journey_id/steps
  29. def create
  30. step = @journey.journey_steps.build(step_params)
  31. # Set position if not provided
  32. if step.position.nil?
  33. max_position = @journey.journey_steps.maximum(:position) || 0
  34. step.position = max_position + 1
  35. end
  36. if step.save
  37. render_success(
  38. data: serialize_step_detail(step),
  39. message: 'Step created successfully',
  40. status: :created
  41. )
  42. else
  43. render_error(
  44. message: 'Failed to create step',
  45. errors: step.errors.as_json
  46. )
  47. end
  48. end
  49. # PUT /api/v1/journeys/:journey_id/steps/:id
  50. def update
  51. if @step.update(step_params)
  52. render_success(
  53. data: serialize_step_detail(@step),
  54. message: 'Step updated successfully'
  55. )
  56. else
  57. render_error(
  58. message: 'Failed to update step',
  59. errors: @step.errors.as_json
  60. )
  61. end
  62. end
  63. # DELETE /api/v1/journeys/:journey_id/steps/:id
  64. def destroy
  65. @step.destroy!
  66. render_success(message: 'Step deleted successfully')
  67. end
  68. # PATCH /api/v1/journeys/:journey_id/steps/:id/reorder
  69. def reorder
  70. new_position = params[:position].to_i
  71. if new_position > 0
  72. @step.update!(position: new_position)
  73. render_success(
  74. data: serialize_step_detail(@step),
  75. message: 'Step reordered successfully'
  76. )
  77. else
  78. render_error(message: 'Invalid position')
  79. end
  80. end
  81. # POST /api/v1/journeys/:journey_id/steps/:id/duplicate
  82. def duplicate
  83. begin
  84. new_step = @step.dup
  85. new_step.name = "#{@step.name} (Copy)"
  86. # Set new position
  87. max_position = @journey.journey_steps.maximum(:position) || 0
  88. new_step.position = max_position + 1
  89. new_step.save!
  90. render_success(
  91. data: serialize_step_detail(new_step),
  92. message: 'Step duplicated successfully',
  93. status: :created
  94. )
  95. rescue => e
  96. render_error(message: "Failed to duplicate step: #{e.message}")
  97. end
  98. end
  99. # POST /api/v1/journeys/:journey_id/steps/:id/execute
  100. def execute
  101. execution_params = params.permit(:user_data, metadata: {})
  102. begin
  103. # This would integrate with the journey execution engine
  104. execution_result = execute_step(@step, execution_params)
  105. render_success(
  106. data: execution_result,
  107. message: 'Step executed successfully'
  108. )
  109. rescue => e
  110. render_error(message: "Failed to execute step: #{e.message}")
  111. end
  112. end
  113. # GET /api/v1/journeys/:journey_id/steps/:id/transitions
  114. def transitions
  115. transitions_from = @step.transitions_from.includes(:to_step)
  116. transitions_to = @step.transitions_to.includes(:from_step)
  117. transitions_data = {
  118. outgoing: transitions_from.map { |t| serialize_transition(t) },
  119. incoming: transitions_to.map { |t| serialize_transition(t) }
  120. }
  121. render_success(data: transitions_data)
  122. end
  123. # POST /api/v1/journeys/:journey_id/steps/:id/transitions
  124. def create_transition
  125. transition_params = params.require(:transition).permit(:to_step_id, :condition_type, :condition_data, :weight, metadata: {})
  126. to_step = @journey.journey_steps.find(transition_params[:to_step_id])
  127. transition = @step.transitions_from.build(transition_params.merge(to_step: to_step))
  128. if transition.save
  129. render_success(
  130. data: serialize_transition(transition),
  131. message: 'Transition created successfully',
  132. status: :created
  133. )
  134. else
  135. render_error(
  136. message: 'Failed to create transition',
  137. errors: transition.errors.as_json
  138. )
  139. end
  140. end
  141. # GET /api/v1/journeys/:journey_id/steps/:id/analytics
  142. def analytics
  143. days = [params[:days].to_i, 1].max
  144. days = [days, 365].min
  145. # Get step execution analytics
  146. executions = @step.step_executions
  147. .where(created_at: days.days.ago..Time.current)
  148. .includes(:journey_execution)
  149. analytics_data = {
  150. execution_count: executions.count,
  151. completion_rate: calculate_step_completion_rate(executions),
  152. average_duration: calculate_average_duration(executions),
  153. success_rate: calculate_step_success_rate(executions),
  154. conversion_metrics: calculate_step_conversions(executions),
  155. engagement_metrics: calculate_step_engagement(executions)
  156. }
  157. render_success(data: analytics_data)
  158. end
  159. private
  160. def set_journey
  161. @journey = current_user.journeys.find(params[:journey_id])
  162. end
  163. def set_step
  164. @step = @journey.journey_steps.find(params[:id])
  165. end
  166. def step_params
  167. params.require(:step).permit(
  168. :name, :description, :step_type, :stage, :position, :timing,
  169. :status, :trigger_conditions, :success_criteria,
  170. content: {}, metadata: {}, settings: {}
  171. )
  172. end
  173. def serialize_step_summary(step)
  174. {
  175. id: step.id,
  176. name: step.name,
  177. description: step.description,
  178. step_type: step.step_type,
  179. stage: step.stage,
  180. position: step.position,
  181. status: step.status,
  182. timing: step.timing,
  183. created_at: step.created_at,
  184. updated_at: step.updated_at
  185. }
  186. end
  187. def serialize_step_detail(step)
  188. {
  189. id: step.id,
  190. journey_id: step.journey_id,
  191. name: step.name,
  192. description: step.description,
  193. step_type: step.step_type,
  194. stage: step.stage,
  195. position: step.position,
  196. timing: step.timing,
  197. status: step.status,
  198. trigger_conditions: step.trigger_conditions,
  199. success_criteria: step.success_criteria,
  200. content: step.content,
  201. metadata: step.metadata,
  202. settings: step.settings,
  203. created_at: step.created_at,
  204. updated_at: step.updated_at,
  205. transitions_count: {
  206. outgoing: step.transitions_from.count,
  207. incoming: step.transitions_to.count
  208. }
  209. }
  210. end
  211. def serialize_transition(transition)
  212. {
  213. id: transition.id,
  214. from_step_id: transition.from_step_id,
  215. to_step_id: transition.to_step_id,
  216. from_step_name: transition.from_step.name,
  217. to_step_name: transition.to_step.name,
  218. condition_type: transition.condition_type,
  219. condition_data: transition.condition_data,
  220. weight: transition.weight,
  221. metadata: transition.metadata,
  222. created_at: transition.created_at
  223. }
  224. end
  225. def execute_step(step, execution_params)
  226. # Placeholder for step execution logic
  227. # This would integrate with the journey execution engine
  228. {
  229. step_id: step.id,
  230. execution_id: SecureRandom.uuid,
  231. status: 'executed',
  232. executed_at: Time.current,
  233. result: 'success',
  234. metadata: execution_params[:metadata] || {}
  235. }
  236. end
  237. def calculate_step_completion_rate(executions)
  238. return 0.0 if executions.empty?
  239. completed = executions.select { |e| e.status == 'completed' }.count
  240. (completed.to_f / executions.count * 100).round(2)
  241. end
  242. def calculate_average_duration(executions)
  243. durations = executions.filter_map do |e|
  244. next unless e.completed_at && e.started_at
  245. (e.completed_at - e.started_at).to_i
  246. end
  247. return 0 if durations.empty?
  248. (durations.sum.to_f / durations.count).round(2)
  249. end
  250. def calculate_step_success_rate(executions)
  251. return 0.0 if executions.empty?
  252. successful = executions.select { |e| %w[completed success].include?(e.status) }.count
  253. (successful.to_f / executions.count * 100).round(2)
  254. end
  255. def calculate_step_conversions(executions)
  256. # Placeholder for conversion tracking
  257. {
  258. total_conversions: 0,
  259. conversion_rate: 0.0,
  260. conversion_value: 0.0
  261. }
  262. end
  263. def calculate_step_engagement(executions)
  264. # Placeholder for engagement metrics
  265. {
  266. engagement_score: 0.0,
  267. interaction_count: 0,
  268. average_time_spent: 0.0
  269. }
  270. end
  271. end

app/controllers/api/v1/journey_suggestions_controller.rb

0.0% lines covered

433 relevant lines. 0 lines covered and 433 lines missed.
    
  1. class Api::V1::JourneySuggestionsController < Api::V1::BaseController
  2. def index
  3. suggestions = generate_suggestions_for_journey
  4. render_success(data: { suggestions: suggestions })
  5. end
  6. def for_stage
  7. stage = params[:stage]
  8. unless Journey::STAGES.include?(stage)
  9. return render_error(message: 'Invalid stage specified', code: 'INVALID_STAGE')
  10. end
  11. suggestions = generate_suggestions_for_stage(stage)
  12. render_success(data: { suggestions: suggestions })
  13. end
  14. def for_step
  15. step_data = params.permit(:type, :stage, :previous_steps => [], :journey_context => {})
  16. suggestions = generate_suggestions_for_step(step_data)
  17. render_success(data: { suggestions: suggestions })
  18. end
  19. def bulk_suggestions
  20. request_params = params.permit(:journey_id, :count, stages: [], context: {})
  21. journey = current_user.journeys.find(request_params[:journey_id]) if request_params[:journey_id]
  22. stages = request_params[:stages] || Journey::STAGES
  23. count_per_stage = [request_params[:count].to_i, 3].max
  24. count_per_stage = [count_per_stage, 10].min # Cap at 10 per stage
  25. bulk_suggestions = {}
  26. stages.each do |stage|
  27. next unless Journey::STAGES.include?(stage)
  28. suggestions = generate_suggestions_for_stage(stage)
  29. bulk_suggestions[stage] = suggestions.take(count_per_stage)
  30. end
  31. render_success(
  32. data: {
  33. bulk_suggestions: bulk_suggestions,
  34. journey_context: journey ? serialize_journey_context(journey) : nil
  35. }
  36. )
  37. end
  38. def personalized_suggestions
  39. persona_id = params[:persona_id]
  40. campaign_id = params[:campaign_id]
  41. journey_id = params[:journey_id]
  42. context = build_personalization_context(persona_id, campaign_id, journey_id)
  43. suggestions = generate_personalized_suggestions(context)
  44. render_success(
  45. data: {
  46. suggestions: suggestions,
  47. personalization_context: context
  48. }
  49. )
  50. end
  51. def create_feedback
  52. feedback_params = params.permit(:suggestion_id, :feedback_type, :rating, :comment, :journey_id, :step_id)
  53. begin
  54. feedback = current_user.suggestion_feedbacks.create!(
  55. suggestion_id: feedback_params[:suggestion_id],
  56. feedback_type: feedback_params[:feedback_type],
  57. rating: feedback_params[:rating],
  58. comment: feedback_params[:comment],
  59. journey_id: feedback_params[:journey_id],
  60. metadata: {
  61. step_id: feedback_params[:step_id],
  62. created_via_api: true,
  63. user_agent: request.user_agent
  64. }
  65. )
  66. render_success(
  67. data: serialize_feedback(feedback),
  68. message: 'Feedback recorded successfully'
  69. )
  70. rescue => e
  71. render_error(message: "Failed to record feedback: #{e.message}")
  72. end
  73. end
  74. def feedback_analytics
  75. # Get feedback analytics for improving suggestions
  76. days = [params[:days].to_i, 30].max
  77. days = [days, 365].min
  78. start_date = days.days.ago
  79. feedbacks = current_user.suggestion_feedbacks.where(created_at: start_date..)
  80. analytics = {
  81. total_feedback_count: feedbacks.count,
  82. average_rating: feedbacks.average(:rating)&.round(2) || 0,
  83. feedback_by_type: feedbacks.group(:feedback_type).count,
  84. rating_distribution: feedbacks.group(:rating).count,
  85. top_suggestions: find_top_rated_suggestions(feedbacks),
  86. improvement_areas: identify_improvement_areas(feedbacks)
  87. }
  88. render_success(data: analytics)
  89. end
  90. def suggestion_history
  91. journey_id = params[:journey_id]
  92. days = [params[:days].to_i, 30].max
  93. days = [days, 90].min
  94. # This would track suggestion history in a real implementation
  95. history_data = {
  96. suggestions_generated: 0,
  97. suggestions_used: 0,
  98. user_satisfaction: 0.0,
  99. popular_suggestion_types: [],
  100. trend_analysis: {}
  101. }
  102. render_success(data: history_data)
  103. end
  104. def refresh_cache
  105. # Clear and refresh suggestion caches
  106. # This would integrate with the caching system
  107. render_success(message: 'Suggestion cache refreshed successfully')
  108. end
  109. private
  110. def generate_suggestions_for_journey
  111. # Generate general journey suggestions based on user context
  112. [
  113. {
  114. id: 'welcome-email-001',
  115. type: 'step',
  116. title: 'Welcome Email Sequence',
  117. description: 'Start with a personalized welcome email to introduce your brand',
  118. confidence: 0.95,
  119. data: {
  120. step_type: 'email_sequence',
  121. stage: 'awareness',
  122. timing: 'immediate',
  123. subject: 'Welcome to [Brand Name]!',
  124. template: 'welcome'
  125. }
  126. },
  127. {
  128. id: 'social-proof-002',
  129. type: 'step',
  130. title: 'Social Media Engagement',
  131. description: 'Share customer testimonials on social media',
  132. confidence: 0.88,
  133. data: {
  134. step_type: 'social_media',
  135. stage: 'consideration',
  136. timing: '3_days',
  137. channel: 'facebook'
  138. }
  139. },
  140. {
  141. id: 'nurture-sequence-003',
  142. type: 'step',
  143. title: 'Educational Content Series',
  144. description: 'Provide valuable content to nurture leads',
  145. confidence: 0.92,
  146. data: {
  147. step_type: 'blog_post',
  148. stage: 'consideration',
  149. timing: '1_week'
  150. }
  151. }
  152. ]
  153. end
  154. def generate_suggestions_for_stage(stage)
  155. stage_suggestions = {
  156. 'awareness' => [
  157. {
  158. id: "#{stage}-blog-001",
  159. type: 'step',
  160. title: 'Educational Blog Post',
  161. description: 'Create content that addresses common pain points',
  162. confidence: 0.90,
  163. data: {
  164. step_type: 'blog_post',
  165. stage: stage,
  166. timing: 'immediate'
  167. }
  168. },
  169. {
  170. id: "#{stage}-social-001",
  171. type: 'step',
  172. title: 'Social Media Campaign',
  173. description: 'Reach new audiences through targeted social content',
  174. confidence: 0.85,
  175. data: {
  176. step_type: 'social_media',
  177. stage: stage,
  178. timing: 'immediate'
  179. }
  180. },
  181. {
  182. id: "#{stage}-lead-magnet-001",
  183. type: 'step',
  184. title: 'Lead Magnet',
  185. description: 'Offer valuable resource to capture leads',
  186. confidence: 0.93,
  187. data: {
  188. step_type: 'lead_magnet',
  189. stage: stage,
  190. timing: 'immediate'
  191. }
  192. }
  193. ],
  194. 'consideration' => [
  195. {
  196. id: "#{stage}-email-sequence-001",
  197. type: 'step',
  198. title: 'Nurture Email Sequence',
  199. description: 'Build relationships with educational content',
  200. confidence: 0.95,
  201. data: {
  202. step_type: 'email_sequence',
  203. stage: stage,
  204. timing: '1_day'
  205. }
  206. },
  207. {
  208. id: "#{stage}-webinar-001",
  209. type: 'step',
  210. title: 'Educational Webinar',
  211. description: 'Demonstrate expertise and build trust',
  212. confidence: 0.88,
  213. data: {
  214. step_type: 'webinar',
  215. stage: stage,
  216. timing: '1_week'
  217. }
  218. },
  219. {
  220. id: "#{stage}-case-study-001",
  221. type: 'step',
  222. title: 'Customer Case Study',
  223. description: 'Show real results and social proof',
  224. confidence: 0.91,
  225. data: {
  226. step_type: 'case_study',
  227. stage: stage,
  228. timing: '3_days'
  229. }
  230. }
  231. ],
  232. 'conversion' => [
  233. {
  234. id: "#{stage}-sales-call-001",
  235. type: 'step',
  236. title: 'Consultation Call',
  237. description: 'Personal conversation to address specific needs',
  238. confidence: 0.97,
  239. data: {
  240. step_type: 'sales_call',
  241. stage: stage,
  242. timing: '1_day'
  243. }
  244. },
  245. {
  246. id: "#{stage}-demo-001",
  247. type: 'step',
  248. title: 'Product Demonstration',
  249. description: 'Show how your solution solves their problems',
  250. confidence: 0.92,
  251. data: {
  252. step_type: 'demo',
  253. stage: stage,
  254. timing: 'immediate'
  255. }
  256. },
  257. {
  258. id: "#{stage}-trial-001",
  259. type: 'step',
  260. title: 'Free Trial Offer',
  261. description: 'Let prospects experience your product risk-free',
  262. confidence: 0.89,
  263. data: {
  264. step_type: 'trial_offer',
  265. stage: stage,
  266. timing: 'immediate'
  267. }
  268. }
  269. ],
  270. 'retention' => [
  271. {
  272. id: "#{stage}-onboarding-001",
  273. type: 'step',
  274. title: 'Customer Onboarding',
  275. description: 'Ensure new customers get maximum value',
  276. confidence: 0.98,
  277. data: {
  278. step_type: 'onboarding',
  279. stage: stage,
  280. timing: 'immediate'
  281. }
  282. },
  283. {
  284. id: "#{stage}-newsletter-001",
  285. type: 'step',
  286. title: 'Regular Newsletter',
  287. description: 'Keep customers engaged with updates and tips',
  288. confidence: 0.86,
  289. data: {
  290. step_type: 'newsletter',
  291. stage: stage,
  292. timing: '1_week'
  293. }
  294. },
  295. {
  296. id: "#{stage}-feedback-001",
  297. type: 'step',
  298. title: 'Feedback Survey',
  299. description: 'Gather insights to improve customer experience',
  300. confidence: 0.82,
  301. data: {
  302. step_type: 'feedback_survey',
  303. stage: stage,
  304. timing: '2_weeks'
  305. }
  306. }
  307. ]
  308. }
  309. stage_suggestions[stage] || []
  310. end
  311. def generate_suggestions_for_step(step_data)
  312. suggestions = []
  313. # Analyze previous steps to suggest next logical steps
  314. previous_steps = step_data[:previous_steps] || []
  315. current_stage = step_data[:stage]
  316. # Logic to suggest next steps based on current step type and stage
  317. case step_data[:type]
  318. when 'lead_magnet'
  319. suggestions << {
  320. id: 'follow-up-email-001',
  321. type: 'connection',
  322. title: 'Follow-up Email',
  323. description: 'Send a thank you email with additional resources',
  324. confidence: 0.95,
  325. data: {
  326. step_type: 'email_sequence',
  327. stage: 'consideration',
  328. timing: '1_day',
  329. subject: 'Thank you for downloading [Resource Name]'
  330. }
  331. }
  332. when 'email_sequence'
  333. suggestions << {
  334. id: 'social-engagement-001',
  335. type: 'connection',
  336. title: 'Social Media Follow-up',
  337. description: 'Engage prospects on social media',
  338. confidence: 0.85,
  339. data: {
  340. step_type: 'social_media',
  341. stage: current_stage,
  342. timing: '2_days'
  343. }
  344. }
  345. when 'webinar'
  346. suggestions << {
  347. id: 'sales-call-follow-001',
  348. type: 'connection',
  349. title: 'Sales Call',
  350. description: 'Schedule a call with interested attendees',
  351. confidence: 0.92,
  352. data: {
  353. step_type: 'sales_call',
  354. stage: 'conversion',
  355. timing: '1_day'
  356. }
  357. }
  358. end
  359. suggestions
  360. end
  361. def serialize_journey_context(journey)
  362. {
  363. id: journey.id,
  364. name: journey.name,
  365. campaign_type: journey.campaign_type,
  366. target_audience: journey.target_audience,
  367. step_count: journey.total_steps,
  368. stages_used: journey.steps_by_stage.keys
  369. }
  370. end
  371. def build_personalization_context(persona_id, campaign_id, journey_id)
  372. context = {}
  373. if persona_id.present?
  374. persona = current_user.personas.find_by(id: persona_id)
  375. context[:persona] = persona.to_campaign_context if persona
  376. end
  377. if campaign_id.present?
  378. campaign = current_user.campaigns.find_by(id: campaign_id)
  379. context[:campaign] = campaign.to_analytics_context if campaign
  380. end
  381. if journey_id.present?
  382. journey = current_user.journeys.find_by(id: journey_id)
  383. context[:journey] = serialize_journey_context(journey) if journey
  384. end
  385. context
  386. end
  387. def generate_personalized_suggestions(context)
  388. # Enhanced suggestions based on persona, campaign, and journey context
  389. base_suggestions = generate_suggestions_for_journey
  390. # Customize suggestions based on context
  391. if context[:persona]
  392. base_suggestions = filter_suggestions_by_persona(base_suggestions, context[:persona])
  393. end
  394. if context[:campaign]
  395. base_suggestions = enhance_suggestions_with_campaign_data(base_suggestions, context[:campaign])
  396. end
  397. base_suggestions
  398. end
  399. def filter_suggestions_by_persona(suggestions, persona_context)
  400. # Filter and prioritize suggestions based on persona characteristics
  401. suggestions.map do |suggestion|
  402. # Adjust confidence scores based on persona fit
  403. if persona_context[:age_range] == '25-35' && suggestion[:data][:step_type] == 'social_media'
  404. suggestion[:confidence] = [suggestion[:confidence] * 1.1, 1.0].min
  405. end
  406. suggestion
  407. end
  408. end
  409. def enhance_suggestions_with_campaign_data(suggestions, campaign_context)
  410. # Enhance suggestions with campaign-specific data
  411. suggestions.map do |suggestion|
  412. suggestion[:data][:campaign_context] = {
  413. campaign_type: campaign_context[:campaign_type],
  414. industry: campaign_context[:industry]
  415. }
  416. suggestion
  417. end
  418. end
  419. def serialize_feedback(feedback)
  420. {
  421. id: feedback.id,
  422. suggestion_id: feedback.suggestion_id,
  423. feedback_type: feedback.feedback_type,
  424. rating: feedback.rating,
  425. comment: feedback.comment,
  426. journey_id: feedback.journey_id,
  427. created_at: feedback.created_at
  428. }
  429. end
  430. def find_top_rated_suggestions(feedbacks)
  431. feedbacks.group(:suggestion_id)
  432. .average(:rating)
  433. .sort_by { |_, rating| -rating }
  434. .first(5)
  435. .map { |suggestion_id, rating| { suggestion_id: suggestion_id, rating: rating.round(2) } }
  436. end
  437. def identify_improvement_areas(feedbacks)
  438. low_rated = feedbacks.where('rating < ?', 3)
  439. areas = []
  440. areas << 'Suggestion relevance' if low_rated.where(feedback_type: 'relevance').count > low_rated.count * 0.3
  441. areas << 'Suggestion quality' if low_rated.where(feedback_type: 'quality').count > low_rated.count * 0.3
  442. areas << 'Implementation difficulty' if low_rated.where(feedback_type: 'difficulty').count > low_rated.count * 0.3
  443. areas
  444. end
  445. end

app/controllers/api/v1/journey_templates_controller.rb

0.0% lines covered

224 relevant lines. 0 lines covered and 224 lines missed.
    
  1. class Api::V1::JourneyTemplatesController < Api::V1::BaseController
  2. before_action :set_template, only: [:show, :instantiate, :update, :destroy]
  3. # GET /api/v1/templates
  4. def index
  5. templates = JourneyTemplate.published.includes(:user)
  6. # Apply filters
  7. templates = templates.where(category: params[:category]) if params[:category].present?
  8. templates = templates.where(industry: params[:industry]) if params[:industry].present?
  9. templates = templates.where('name ILIKE ? OR description ILIKE ?', "%#{params[:search]}%", "%#{params[:search]}%") if params[:search].present?
  10. # Filter by template type
  11. if params[:template_type].present?
  12. templates = templates.where("metadata ->> 'template_type' = ?", params[:template_type])
  13. end
  14. # Filter by difficulty level
  15. if params[:difficulty].present?
  16. templates = templates.where("metadata ->> 'difficulty' = ?", params[:difficulty])
  17. end
  18. # Apply sorting
  19. case params[:sort_by]
  20. when 'name'
  21. templates = templates.order(:name)
  22. when 'category'
  23. templates = templates.order(:category, :name)
  24. when 'popularity'
  25. templates = templates.order(usage_count: :desc, name: :asc)
  26. when 'rating'
  27. templates = templates.order('metadata->>\'rating\' DESC NULLS LAST', :name)
  28. when 'created_at'
  29. templates = templates.order(:created_at)
  30. else
  31. templates = templates.order(:name)
  32. end
  33. paginate_and_render(templates, serializer: method(:serialize_template_summary))
  34. end
  35. # GET /api/v1/templates/:id
  36. def show
  37. render_success(data: serialize_template_detail(@template))
  38. end
  39. # POST /api/v1/templates
  40. def create
  41. template = current_user.journey_templates.build(template_params)
  42. if template.save
  43. render_success(
  44. data: serialize_template_detail(template),
  45. message: 'Template created successfully',
  46. status: :created
  47. )
  48. else
  49. render_error(
  50. message: 'Failed to create template',
  51. errors: template.errors.as_json
  52. )
  53. end
  54. end
  55. # PUT /api/v1/templates/:id
  56. def update
  57. # Only allow template owner to update
  58. unless @template.user == current_user
  59. return render_error(message: 'Access denied', status: :forbidden)
  60. end
  61. if @template.update(template_params)
  62. render_success(
  63. data: serialize_template_detail(@template),
  64. message: 'Template updated successfully'
  65. )
  66. else
  67. render_error(
  68. message: 'Failed to update template',
  69. errors: @template.errors.as_json
  70. )
  71. end
  72. end
  73. # DELETE /api/v1/templates/:id
  74. def destroy
  75. # Only allow template owner to delete
  76. unless @template.user == current_user
  77. return render_error(message: 'Access denied', status: :forbidden)
  78. end
  79. @template.destroy!
  80. render_success(message: 'Template deleted successfully')
  81. end
  82. # POST /api/v1/templates/:id/instantiate
  83. def instantiate
  84. instantiation_params = params.permit(:name, :description, :campaign_id, customizations: {})
  85. begin
  86. journey = @template.instantiate_for_user(current_user, instantiation_params)
  87. # Increment usage count
  88. @template.increment!(:usage_count)
  89. render_success(
  90. data: serialize_instantiated_journey(journey),
  91. message: 'Template instantiated successfully',
  92. status: :created
  93. )
  94. rescue => e
  95. render_error(message: "Failed to instantiate template: #{e.message}")
  96. end
  97. end
  98. # POST /api/v1/templates/:id/clone
  99. def clone
  100. begin
  101. new_template = @template.dup
  102. new_template.user = current_user
  103. new_template.name = "#{@template.name} (Copy)"
  104. new_template.is_public = false
  105. new_template.status = 'draft'
  106. new_template.usage_count = 0
  107. new_template.save!
  108. render_success(
  109. data: serialize_template_detail(new_template),
  110. message: 'Template cloned successfully',
  111. status: :created
  112. )
  113. rescue => e
  114. render_error(message: "Failed to clone template: #{e.message}")
  115. end
  116. end
  117. # GET /api/v1/templates/categories
  118. def categories
  119. categories = JourneyTemplate.published.distinct.pluck(:category).compact.sort
  120. render_success(data: categories)
  121. end
  122. # GET /api/v1/templates/industries
  123. def industries
  124. industries = JourneyTemplate.published.distinct.pluck(:industry).compact.sort
  125. render_success(data: industries)
  126. end
  127. # GET /api/v1/templates/popular
  128. def popular
  129. limit = [params[:limit].to_i, 1].max
  130. limit = [limit, 50].min # Cap at 50
  131. templates = JourneyTemplate.published
  132. .order(usage_count: :desc, name: :asc)
  133. .limit(limit)
  134. render_success(data: templates.map { |t| serialize_template_summary(t) })
  135. end
  136. # GET /api/v1/templates/recommended
  137. def recommended
  138. # Basic recommendation based on user's journey types and industries
  139. user_campaign_types = current_user.journeys.distinct.pluck(:campaign_type).compact
  140. user_industries = current_user.journeys.joins(:campaign).distinct.pluck('campaigns.industry').compact
  141. recommendations = JourneyTemplate.published
  142. if user_campaign_types.any?
  143. recommendations = recommendations.where(
  144. "metadata ->> 'recommended_for' ?| array[?]",
  145. user_campaign_types
  146. )
  147. end
  148. if user_industries.any?
  149. recommendations = recommendations.where(industry: user_industries)
  150. end
  151. # Fallback to popular templates if no specific recommendations
  152. if recommendations.empty?
  153. recommendations = JourneyTemplate.published.order(usage_count: :desc)
  154. end
  155. limit = [params[:limit].to_i, 10].max
  156. limit = [limit, 20].min
  157. render_success(
  158. data: recommendations.limit(limit).map { |t| serialize_template_summary(t) }
  159. )
  160. end
  161. # POST /api/v1/templates/:id/rate
  162. def rate
  163. rating = params[:rating].to_f
  164. comment = params[:comment]
  165. unless (1..5).include?(rating)
  166. return render_error(message: 'Rating must be between 1 and 5')
  167. end
  168. # Store rating in template metadata
  169. ratings = @template.metadata['ratings'] || []
  170. ratings << {
  171. user_id: current_user.id,
  172. rating: rating,
  173. comment: comment,
  174. created_at: Time.current
  175. }
  176. @template.metadata['ratings'] = ratings
  177. # Calculate average rating
  178. avg_rating = ratings.sum { |r| r['rating'] } / ratings.count.to_f
  179. @template.metadata['rating'] = avg_rating.round(2)
  180. @template.save!
  181. render_success(
  182. data: { rating: avg_rating, total_ratings: ratings.count },
  183. message: 'Rating submitted successfully'
  184. )
  185. end
  186. private
  187. def set_template
  188. @template = JourneyTemplate.find(params[:id])
  189. end
  190. def template_params
  191. params.require(:template).permit(
  192. :name, :description, :category, :industry, :is_public, :status,
  193. steps_template: [], metadata: {}
  194. )
  195. end
  196. def serialize_template_summary(template)
  197. {
  198. id: template.id,
  199. name: template.name,
  200. description: template.description,
  201. category: template.category,
  202. industry: template.industry,
  203. author: template.user.name,
  204. usage_count: template.usage_count,
  205. rating: template.metadata['rating'],
  206. total_ratings: (template.metadata['ratings'] || []).count,
  207. difficulty: template.metadata['difficulty'],
  208. estimated_duration: template.metadata['estimated_duration'],
  209. step_count: (template.steps_template || []).count,
  210. created_at: template.created_at,
  211. updated_at: template.updated_at
  212. }
  213. end
  214. def serialize_template_detail(template)
  215. {
  216. id: template.id,
  217. name: template.name,
  218. description: template.description,
  219. category: template.category,
  220. industry: template.industry,
  221. is_public: template.is_public,
  222. status: template.status,
  223. author: {
  224. id: template.user.id,
  225. name: template.user.name
  226. },
  227. usage_count: template.usage_count,
  228. rating: template.metadata['rating'],
  229. total_ratings: (template.metadata['ratings'] || []).count,
  230. steps_template: template.steps_template,
  231. metadata: template.metadata,
  232. version: template.version,
  233. created_at: template.created_at,
  234. updated_at: template.updated_at
  235. }
  236. end
  237. def serialize_instantiated_journey(journey)
  238. {
  239. id: journey.id,
  240. name: journey.name,
  241. description: journey.description,
  242. status: journey.status,
  243. template_id: journey.metadata['template_id'],
  244. created_at: journey.created_at
  245. }
  246. end
  247. end

app/controllers/api/v1/journeys_controller.rb

0.0% lines covered

193 relevant lines. 0 lines covered and 193 lines missed.
    
  1. class Api::V1::JourneysController < Api::V1::BaseController
  2. before_action :set_journey, only: [:show, :update, :destroy, :duplicate, :publish, :archive]
  3. # GET /api/v1/journeys
  4. def index
  5. journeys = current_user.journeys.includes(:campaign, :persona, :journey_steps)
  6. # Apply filters
  7. journeys = journeys.where(status: params[:status]) if params[:status].present?
  8. journeys = journeys.where(campaign_type: params[:campaign_type]) if params[:campaign_type].present?
  9. journeys = journeys.joins(:campaign).where(campaigns: { id: params[:campaign_id] }) if params[:campaign_id].present?
  10. # Apply sorting
  11. case params[:sort_by]
  12. when 'name'
  13. journeys = journeys.order(:name)
  14. when 'created_at'
  15. journeys = journeys.order(:created_at)
  16. when 'updated_at'
  17. journeys = journeys.order(:updated_at)
  18. when 'status'
  19. journeys = journeys.order(:status)
  20. else
  21. journeys = journeys.order(updated_at: :desc)
  22. end
  23. paginate_and_render(journeys, serializer: method(:serialize_journey_summary))
  24. end
  25. # GET /api/v1/journeys/:id
  26. def show
  27. render_success(data: serialize_journey_detail(@journey))
  28. end
  29. # POST /api/v1/journeys
  30. def create
  31. journey = current_user.journeys.build(journey_params)
  32. if journey.save
  33. render_success(
  34. data: serialize_journey_detail(journey),
  35. message: 'Journey created successfully',
  36. status: :created
  37. )
  38. else
  39. render_error(
  40. message: 'Failed to create journey',
  41. errors: journey.errors.as_json
  42. )
  43. end
  44. end
  45. # PUT /api/v1/journeys/:id
  46. def update
  47. if @journey.update(journey_params)
  48. render_success(
  49. data: serialize_journey_detail(@journey),
  50. message: 'Journey updated successfully'
  51. )
  52. else
  53. render_error(
  54. message: 'Failed to update journey',
  55. errors: @journey.errors.as_json
  56. )
  57. end
  58. end
  59. # DELETE /api/v1/journeys/:id
  60. def destroy
  61. @journey.destroy!
  62. render_success(message: 'Journey deleted successfully')
  63. end
  64. # POST /api/v1/journeys/:id/duplicate
  65. def duplicate
  66. begin
  67. new_journey = @journey.duplicate
  68. render_success(
  69. data: serialize_journey_detail(new_journey),
  70. message: 'Journey duplicated successfully',
  71. status: :created
  72. )
  73. rescue => e
  74. render_error(message: "Failed to duplicate journey: #{e.message}")
  75. end
  76. end
  77. # POST /api/v1/journeys/:id/publish
  78. def publish
  79. if @journey.publish!
  80. render_success(
  81. data: serialize_journey_detail(@journey),
  82. message: 'Journey published successfully'
  83. )
  84. else
  85. render_error(
  86. message: 'Failed to publish journey',
  87. errors: @journey.errors.as_json
  88. )
  89. end
  90. end
  91. # POST /api/v1/journeys/:id/archive
  92. def archive
  93. if @journey.archive!
  94. render_success(
  95. data: serialize_journey_detail(@journey),
  96. message: 'Journey archived successfully'
  97. )
  98. else
  99. render_error(
  100. message: 'Failed to archive journey',
  101. errors: @journey.errors.as_json
  102. )
  103. end
  104. end
  105. # GET /api/v1/journeys/:id/analytics
  106. def analytics
  107. days = [params[:days].to_i, 1].max
  108. days = [days, 365].min # Cap at 1 year
  109. analytics_data = {
  110. summary: @journey.analytics_summary(days),
  111. performance_score: @journey.latest_performance_score,
  112. funnel_performance: @journey.funnel_performance('default', days),
  113. trends: @journey.performance_trends(7),
  114. ab_test_status: @journey.ab_test_status
  115. }
  116. render_success(data: analytics_data)
  117. end
  118. # GET /api/v1/journeys/:id/execution_status
  119. def execution_status
  120. executions = @journey.journey_executions
  121. .includes(:step_executions)
  122. .order(created_at: :desc)
  123. .limit(params[:limit]&.to_i || 10)
  124. execution_data = executions.map do |execution|
  125. {
  126. id: execution.id,
  127. status: execution.status,
  128. started_at: execution.started_at,
  129. completed_at: execution.completed_at,
  130. current_step_id: execution.current_step_id,
  131. step_count: execution.step_executions.count,
  132. completion_percentage: execution.completion_percentage,
  133. metadata: execution.metadata
  134. }
  135. end
  136. render_success(data: execution_data)
  137. end
  138. private
  139. def set_journey
  140. @journey = current_user.journeys.find(params[:id])
  141. end
  142. def journey_params
  143. params.require(:journey).permit(
  144. :name, :description, :campaign_type, :target_audience, :status,
  145. :campaign_id, goals: [], metadata: {}, settings: {}
  146. )
  147. end
  148. def serialize_journey_summary(journey)
  149. {
  150. id: journey.id,
  151. name: journey.name,
  152. description: journey.description,
  153. status: journey.status,
  154. campaign_type: journey.campaign_type,
  155. campaign_id: journey.campaign_id,
  156. campaign_name: journey.campaign&.name,
  157. persona_name: journey.persona&.name,
  158. step_count: journey.total_steps,
  159. created_at: journey.created_at,
  160. updated_at: journey.updated_at,
  161. published_at: journey.published_at,
  162. performance_score: journey.latest_performance_score
  163. }
  164. end
  165. def serialize_journey_detail(journey)
  166. {
  167. id: journey.id,
  168. name: journey.name,
  169. description: journey.description,
  170. status: journey.status,
  171. campaign_type: journey.campaign_type,
  172. target_audience: journey.target_audience,
  173. goals: journey.goals,
  174. metadata: journey.metadata,
  175. settings: journey.settings,
  176. campaign_id: journey.campaign_id,
  177. campaign: journey.campaign ? serialize_campaign_summary(journey.campaign) : nil,
  178. persona: journey.persona ? serialize_persona_summary(journey.persona) : nil,
  179. step_count: journey.total_steps,
  180. steps_by_stage: journey.steps_by_stage,
  181. created_at: journey.created_at,
  182. updated_at: journey.updated_at,
  183. published_at: journey.published_at,
  184. archived_at: journey.archived_at,
  185. performance_score: journey.latest_performance_score,
  186. ab_test_status: journey.ab_test_status
  187. }
  188. end
  189. def serialize_campaign_summary(campaign)
  190. {
  191. id: campaign.id,
  192. name: campaign.name,
  193. campaign_type: campaign.campaign_type,
  194. status: campaign.status
  195. }
  196. end
  197. def serialize_persona_summary(persona)
  198. {
  199. id: persona.id,
  200. name: persona.name,
  201. demographic_data: persona.demographic_data,
  202. psychographic_data: persona.psychographic_data
  203. }
  204. end
  205. end

app/controllers/api/v1/personas_controller.rb

0.0% lines covered

375 relevant lines. 0 lines covered and 375 lines missed.
    
  1. class Api::V1::PersonasController < Api::V1::BaseController
  2. before_action :set_persona, only: [:show, :update, :destroy, :campaigns, :performance]
  3. # GET /api/v1/personas
  4. def index
  5. personas = current_user.personas.includes(:campaigns)
  6. # Apply filters
  7. personas = personas.where('age_range && ?', params[:age_range]) if params[:age_range].present?
  8. personas = personas.where('location ILIKE ?', "%#{params[:location]}%") if params[:location].present?
  9. personas = personas.where('industry ILIKE ?', "%#{params[:industry]}%") if params[:industry].present?
  10. # Apply search
  11. if params[:search].present?
  12. personas = personas.where(
  13. 'name ILIKE ? OR description ILIKE ?',
  14. "%#{params[:search]}%", "%#{params[:search]}%"
  15. )
  16. end
  17. # Apply sorting
  18. case params[:sort_by]
  19. when 'name'
  20. personas = personas.order(:name)
  21. when 'age_range'
  22. personas = personas.order(:age_range)
  23. when 'location'
  24. personas = personas.order(:location)
  25. when 'created_at'
  26. personas = personas.order(:created_at)
  27. else
  28. personas = personas.order(:name)
  29. end
  30. paginate_and_render(personas, serializer: method(:serialize_persona_summary))
  31. end
  32. # GET /api/v1/personas/:id
  33. def show
  34. render_success(data: serialize_persona_detail(@persona))
  35. end
  36. # POST /api/v1/personas
  37. def create
  38. persona = current_user.personas.build(persona_params)
  39. if persona.save
  40. render_success(
  41. data: serialize_persona_detail(persona),
  42. message: 'Persona created successfully',
  43. status: :created
  44. )
  45. else
  46. render_error(
  47. message: 'Failed to create persona',
  48. errors: persona.errors.as_json
  49. )
  50. end
  51. end
  52. # PUT /api/v1/personas/:id
  53. def update
  54. if @persona.update(persona_params)
  55. render_success(
  56. data: serialize_persona_detail(@persona),
  57. message: 'Persona updated successfully'
  58. )
  59. else
  60. render_error(
  61. message: 'Failed to update persona',
  62. errors: @persona.errors.as_json
  63. )
  64. end
  65. end
  66. # DELETE /api/v1/personas/:id
  67. def destroy
  68. if @persona.campaigns.any?
  69. render_error(
  70. message: 'Cannot delete persona with associated campaigns',
  71. code: 'PERSONA_IN_USE'
  72. )
  73. else
  74. @persona.destroy!
  75. render_success(message: 'Persona deleted successfully')
  76. end
  77. end
  78. # GET /api/v1/personas/:id/campaigns
  79. def campaigns
  80. campaigns = @persona.campaigns.includes(:journeys)
  81. # Apply filters
  82. campaigns = campaigns.where(status: params[:status]) if params[:status].present?
  83. campaigns = campaigns.where(campaign_type: params[:campaign_type]) if params[:campaign_type].present?
  84. paginate_and_render(campaigns, serializer: method(:serialize_campaign_for_persona))
  85. end
  86. # GET /api/v1/personas/:id/performance
  87. def performance
  88. days = [params[:days].to_i, 30].max
  89. days = [days, 365].min
  90. # Get campaigns and journeys associated with this persona
  91. campaigns = @persona.campaigns.includes(:journeys)
  92. journeys = campaigns.flat_map(&:journeys)
  93. performance_data = {
  94. summary: calculate_persona_summary(@persona, journeys, days),
  95. campaign_performance: calculate_persona_campaign_performance(campaigns, days),
  96. journey_performance: calculate_persona_journey_performance(journeys, days),
  97. engagement_patterns: calculate_persona_engagement_patterns(@persona, days),
  98. conversion_insights: calculate_persona_conversion_insights(@persona, days),
  99. demographic_insights: calculate_demographic_insights(@persona),
  100. recommendations: generate_persona_recommendations(@persona, performance_data)
  101. }
  102. render_success(data: performance_data)
  103. end
  104. # POST /api/v1/personas/:id/clone
  105. def clone
  106. begin
  107. new_persona = @persona.dup
  108. new_persona.name = "#{@persona.name} (Copy)"
  109. new_persona.save!
  110. render_success(
  111. data: serialize_persona_detail(new_persona),
  112. message: 'Persona cloned successfully',
  113. status: :created
  114. )
  115. rescue => e
  116. render_error(message: "Failed to clone persona: #{e.message}")
  117. end
  118. end
  119. # GET /api/v1/personas/templates
  120. def templates
  121. # Predefined persona templates
  122. templates = [
  123. {
  124. name: 'Young Professional',
  125. age_range: '25-35',
  126. location: 'Urban',
  127. demographic_data: {
  128. income_range: '$50,000-$75,000',
  129. education: 'College Graduate',
  130. employment: 'Full-time Professional'
  131. },
  132. psychographic_data: {
  133. interests: ['Career Growth', 'Technology', 'Fitness'],
  134. values: ['Work-life Balance', 'Innovation', 'Achievement'],
  135. lifestyle: 'Fast-paced, Digital-first'
  136. }
  137. },
  138. {
  139. name: 'Family-Oriented Parent',
  140. age_range: '30-45',
  141. location: 'Suburban',
  142. demographic_data: {
  143. income_range: '$60,000-$100,000',
  144. education: 'College Graduate',
  145. family_status: 'Married with Children'
  146. },
  147. psychographic_data: {
  148. interests: ['Family Activities', 'Home Improvement', 'Education'],
  149. values: ['Family', 'Security', 'Quality'],
  150. lifestyle: 'Family-focused, Value-conscious'
  151. }
  152. },
  153. {
  154. name: 'Small Business Owner',
  155. age_range: '35-55',
  156. location: 'Various',
  157. demographic_data: {
  158. income_range: '$75,000-$150,000',
  159. education: 'College/Trade School',
  160. employment: 'Business Owner'
  161. },
  162. psychographic_data: {
  163. interests: ['Business Growth', 'Networking', 'Industry Trends'],
  164. values: ['Independence', 'Success', 'Innovation'],
  165. lifestyle: 'Busy, Results-oriented'
  166. }
  167. }
  168. ]
  169. render_success(data: templates)
  170. end
  171. # POST /api/v1/personas/from_template
  172. def create_from_template
  173. template_data = params.require(:template).permit!
  174. persona = current_user.personas.build(
  175. name: template_data[:name],
  176. description: "Created from #{template_data[:name]} template",
  177. age_range: template_data[:age_range],
  178. location: template_data[:location],
  179. demographic_data: template_data[:demographic_data] || {},
  180. psychographic_data: template_data[:psychographic_data] || {}
  181. )
  182. if persona.save
  183. render_success(
  184. data: serialize_persona_detail(persona),
  185. message: 'Persona created from template successfully',
  186. status: :created
  187. )
  188. else
  189. render_error(
  190. message: 'Failed to create persona from template',
  191. errors: persona.errors.as_json
  192. )
  193. end
  194. end
  195. # GET /api/v1/personas/analytics_overview
  196. def analytics_overview
  197. days = [params[:days].to_i, 30].max
  198. days = [days, 365].min
  199. personas = current_user.personas.includes(:campaigns)
  200. overview_data = {
  201. total_personas: personas.count,
  202. active_personas: personas.joins(:campaigns).where(campaigns: { status: 'active' }).distinct.count,
  203. top_performing: find_top_performing_personas(5, days),
  204. demographic_breakdown: calculate_demographic_breakdown(personas),
  205. usage_statistics: calculate_persona_usage_statistics(personas, days)
  206. }
  207. render_success(data: overview_data)
  208. end
  209. private
  210. def set_persona
  211. @persona = current_user.personas.find(params[:id])
  212. end
  213. def persona_params
  214. params.require(:persona).permit(
  215. :name, :description, :age_range, :location, :industry,
  216. demographic_data: {}, psychographic_data: {}, behavioral_data: {}
  217. )
  218. end
  219. def serialize_persona_summary(persona)
  220. {
  221. id: persona.id,
  222. name: persona.name,
  223. description: persona.description,
  224. age_range: persona.age_range,
  225. location: persona.location,
  226. industry: persona.industry,
  227. campaign_count: persona.campaigns.count,
  228. created_at: persona.created_at,
  229. updated_at: persona.updated_at
  230. }
  231. end
  232. def serialize_persona_detail(persona)
  233. {
  234. id: persona.id,
  235. name: persona.name,
  236. description: persona.description,
  237. age_range: persona.age_range,
  238. location: persona.location,
  239. industry: persona.industry,
  240. demographic_data: persona.demographic_data,
  241. psychographic_data: persona.psychographic_data,
  242. behavioral_data: persona.behavioral_data,
  243. campaign_count: persona.campaigns.count,
  244. campaigns: persona.campaigns.limit(5).map { |c| serialize_campaign_for_persona(c) },
  245. created_at: persona.created_at,
  246. updated_at: persona.updated_at
  247. }
  248. end
  249. def serialize_campaign_for_persona(campaign)
  250. {
  251. id: campaign.id,
  252. name: campaign.name,
  253. campaign_type: campaign.campaign_type,
  254. status: campaign.status,
  255. journey_count: campaign.journeys.count
  256. }
  257. end
  258. def calculate_persona_summary(persona, journeys, days)
  259. {
  260. persona_name: persona.name,
  261. total_campaigns: persona.campaigns.count,
  262. total_journeys: journeys.count,
  263. performance_score: calculate_persona_performance_score(journeys, days)
  264. }
  265. end
  266. def calculate_persona_campaign_performance(campaigns, days)
  267. campaigns.map do |campaign|
  268. journeys = campaign.journeys
  269. avg_performance = journeys.map(&:latest_performance_score).compact
  270. avg_score = avg_performance.any? ? (avg_performance.sum.to_f / avg_performance.count).round(1) : 0
  271. {
  272. id: campaign.id,
  273. name: campaign.name,
  274. status: campaign.status,
  275. journey_count: journeys.count,
  276. average_performance_score: avg_score
  277. }
  278. end
  279. end
  280. def calculate_persona_journey_performance(journeys, days)
  281. journeys.map do |journey|
  282. {
  283. id: journey.id,
  284. name: journey.name,
  285. performance_score: journey.latest_performance_score,
  286. conversion_rate: journey.current_analytics&.conversion_rate || 0,
  287. status: journey.status
  288. }
  289. end
  290. end
  291. def calculate_persona_engagement_patterns(persona, days)
  292. # Analyze engagement patterns for this persona
  293. campaigns = persona.campaigns
  294. {
  295. preferred_journey_types: analyze_preferred_journey_types(campaigns),
  296. optimal_touchpoint_frequency: analyze_touchpoint_frequency(campaigns),
  297. engagement_peak_times: analyze_engagement_times(campaigns),
  298. channel_preferences: analyze_channel_preferences(campaigns)
  299. }
  300. end
  301. def calculate_persona_conversion_insights(persona, days)
  302. campaigns = persona.campaigns
  303. journeys = campaigns.flat_map(&:journeys)
  304. {
  305. average_conversion_rate: calculate_average_conversion_rate(journeys),
  306. conversion_triggers: identify_conversion_triggers(journeys),
  307. optimal_journey_length: calculate_optimal_journey_length(journeys),
  308. successful_touchpoints: identify_successful_touchpoints(journeys)
  309. }
  310. end
  311. def calculate_demographic_insights(persona)
  312. # Analyze how demographic factors influence performance
  313. {
  314. age_segment_performance: analyze_age_segment_performance(persona),
  315. location_impact: analyze_location_impact(persona),
  316. industry_relevance: analyze_industry_relevance(persona)
  317. }
  318. end
  319. def generate_persona_recommendations(persona, performance_data)
  320. recommendations = []
  321. # Generate recommendations based on performance data
  322. if performance_data[:summary][:performance_score] < 50
  323. recommendations << "Consider adjusting journey content to better match persona interests"
  324. end
  325. if persona.campaigns.count == 0
  326. recommendations << "Create campaigns targeting this persona to gather performance data"
  327. end
  328. recommendations
  329. end
  330. def find_top_performing_personas(limit, days)
  331. current_user.personas
  332. .joins(campaigns: { journeys: :journey_analytics })
  333. .group('personas.id, personas.name')
  334. .order('AVG(journey_analytics.conversion_rate) DESC')
  335. .limit(limit)
  336. .pluck('personas.id, personas.name, AVG(journey_analytics.conversion_rate)')
  337. .map { |id, name, rate| { id: id, name: name, conversion_rate: rate&.round(2) || 0 } }
  338. end
  339. def calculate_demographic_breakdown(personas)
  340. {
  341. age_ranges: personas.group(:age_range).count,
  342. locations: personas.group(:location).count,
  343. industries: personas.group(:industry).count
  344. }
  345. end
  346. def calculate_persona_usage_statistics(personas, days)
  347. active_campaigns = personas.joins(:campaigns).where(campaigns: { status: 'active' }).count
  348. {
  349. personas_with_active_campaigns: active_campaigns,
  350. average_campaigns_per_persona: personas.joins(:campaigns).group('personas.id').count.values.sum.to_f / personas.count,
  351. most_used_persona: personas.joins(:campaigns).group('personas.id, personas.name').count.max_by { |_, count| count }
  352. }
  353. end
  354. def calculate_persona_performance_score(journeys, days)
  355. return 0.0 if journeys.empty?
  356. scores = journeys.map(&:latest_performance_score).compact
  357. return 0.0 if scores.empty?
  358. (scores.sum.to_f / scores.count).round(1)
  359. end
  360. def analyze_preferred_journey_types(campaigns)
  361. # Placeholder for journey type analysis
  362. []
  363. end
  364. def analyze_touchpoint_frequency(campaigns)
  365. # Placeholder for touchpoint frequency analysis
  366. 'weekly'
  367. end
  368. def analyze_engagement_times(campaigns)
  369. # Placeholder for engagement time analysis
  370. []
  371. end
  372. def analyze_channel_preferences(campaigns)
  373. # Placeholder for channel preference analysis
  374. []
  375. end
  376. def calculate_average_conversion_rate(journeys)
  377. return 0.0 if journeys.empty?
  378. rates = journeys.map { |j| j.current_analytics&.conversion_rate || 0 }
  379. (rates.sum.to_f / rates.count).round(2)
  380. end
  381. def identify_conversion_triggers(journeys)
  382. # Placeholder for conversion trigger analysis
  383. []
  384. end
  385. def calculate_optimal_journey_length(journeys)
  386. # Placeholder for optimal journey length calculation
  387. 5
  388. end
  389. def identify_successful_touchpoints(journeys)
  390. # Placeholder for successful touchpoint identification
  391. []
  392. end
  393. def analyze_age_segment_performance(persona)
  394. # Placeholder for age segment analysis
  395. {}
  396. end
  397. def analyze_location_impact(persona)
  398. # Placeholder for location impact analysis
  399. {}
  400. end
  401. def analyze_industry_relevance(persona)
  402. # Placeholder for industry relevance analysis
  403. {}
  404. end
  405. end

app/controllers/application_controller.rb

0.0% lines covered

13 relevant lines. 0 lines covered and 13 lines missed.
    
  1. class ApplicationController < ActionController::Base
  2. include Authentication
  3. include Pundit::Authorization
  4. include RailsAdminAuditable
  5. include ActivityTracker
  6. # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
  7. allow_browser versions: :modern
  8. # Pundit authorization error handling
  9. rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
  10. private
  11. def user_not_authorized
  12. flash[:alert] = "You are not authorized to perform this action."
  13. redirect_back(fallback_location: root_path)
  14. end
  15. end

app/controllers/brand_assets_controller.rb

0.0% lines covered

142 relevant lines. 0 lines covered and 142 lines missed.
    
  1. class BrandAssetsController < ApplicationController
  2. before_action :set_brand
  3. before_action :set_brand_asset, only: [:show, :edit, :update, :destroy, :reprocess, :download]
  4. def index
  5. @brand_assets = @brand.brand_assets.includes(:file_attachment)
  6. end
  7. def show
  8. end
  9. def new
  10. @brand_asset = @brand.brand_assets.build
  11. end
  12. def create
  13. if params[:brand_asset][:files].present?
  14. # Handle multiple file uploads
  15. @brand_assets = []
  16. @errors = []
  17. params[:brand_asset][:files].each do |file|
  18. brand_asset = @brand.brand_assets.build(
  19. file: file,
  20. asset_type: determine_asset_type(file),
  21. original_filename: file.original_filename
  22. )
  23. if brand_asset.save
  24. @brand_assets << brand_asset
  25. else
  26. @errors << { filename: file.original_filename, errors: brand_asset.errors.full_messages }
  27. end
  28. end
  29. if request.xhr?
  30. render json: {
  31. success: @errors.empty?,
  32. assets: @brand_assets.map { |asset| asset_json(asset) },
  33. errors: @errors
  34. }
  35. else
  36. if @errors.empty?
  37. redirect_to brand_brand_assets_path(@brand),
  38. notice: "#{@brand_assets.count} asset(s) uploaded successfully."
  39. else
  40. flash[:alert] = "Some files failed to upload: #{@errors.map { |e| e[:filename] }.join(', ')}"
  41. redirect_to new_brand_brand_asset_path(@brand)
  42. end
  43. end
  44. else
  45. # Handle single file upload
  46. @brand_asset = @brand.brand_assets.build(brand_asset_params)
  47. if @brand_asset.save
  48. if request.xhr?
  49. render json: { success: true, asset: asset_json(@brand_asset) }
  50. else
  51. redirect_to brand_brand_asset_path(@brand, @brand_asset),
  52. notice: 'Brand asset was successfully uploaded and is being processed.'
  53. end
  54. else
  55. if request.xhr?
  56. render json: { success: false, errors: @brand_asset.errors.full_messages }, status: :unprocessable_entity
  57. else
  58. render :new, status: :unprocessable_entity
  59. end
  60. end
  61. end
  62. end
  63. def edit
  64. end
  65. def update
  66. if @brand_asset.update(brand_asset_params)
  67. redirect_to brand_brand_asset_path(@brand, @brand_asset),
  68. notice: 'Brand asset was successfully updated.'
  69. else
  70. render :edit, status: :unprocessable_entity
  71. end
  72. end
  73. def destroy
  74. @brand_asset.destroy!
  75. redirect_to brand_brand_assets_url(@brand),
  76. notice: 'Brand asset was successfully destroyed.'
  77. end
  78. def reprocess
  79. @brand_asset.update!(processing_status: 'pending')
  80. BrandAssetProcessingJob.perform_later(@brand_asset)
  81. redirect_to brand_brand_asset_path(@brand, @brand_asset),
  82. notice: 'Brand asset is being reprocessed.'
  83. end
  84. def download
  85. if @brand_asset.file.attached?
  86. redirect_to rails_blob_url(@brand_asset.file, disposition: "attachment")
  87. else
  88. redirect_to brand_brand_assets_url(@brand),
  89. alert: 'No file attached to this asset.'
  90. end
  91. end
  92. # AJAX endpoint for upload status
  93. def status
  94. @brand_asset = @brand.brand_assets.find(params[:id])
  95. render json: asset_json(@brand_asset)
  96. end
  97. # AJAX endpoint for batch status check
  98. def batch_status
  99. asset_ids = params[:asset_ids].split(',')
  100. @brand_assets = @brand.brand_assets.where(id: asset_ids)
  101. render json: {
  102. assets: @brand_assets.map { |asset| asset_json(asset) }
  103. }
  104. end
  105. private
  106. def set_brand
  107. @brand = current_user.brands.find(params[:brand_id])
  108. end
  109. def set_brand_asset
  110. @brand_asset = @brand.brand_assets.find(params[:id])
  111. end
  112. def brand_asset_params
  113. params.require(:brand_asset).permit(:file, :asset_type, :original_filename)
  114. end
  115. def determine_asset_type(file)
  116. content_type = file.content_type
  117. filename = file.original_filename.downcase
  118. case content_type
  119. when *BrandAsset::ALLOWED_CONTENT_TYPES[:image]
  120. return 'logo' if filename.include?('logo')
  121. 'image'
  122. when *BrandAsset::ALLOWED_CONTENT_TYPES[:document]
  123. return 'brand_guidelines' if filename.include?('guideline') || filename.include?('brand')
  124. return 'style_guide' if filename.include?('style')
  125. 'document'
  126. when *BrandAsset::ALLOWED_CONTENT_TYPES[:video]
  127. 'video'
  128. else
  129. 'document' # Default fallback
  130. end
  131. end
  132. def asset_json(asset)
  133. {
  134. id: asset.id,
  135. filename: asset.original_filename,
  136. asset_type: asset.asset_type,
  137. processing_status: asset.processing_status,
  138. file_size: asset.file_size_mb.round(2),
  139. content_type: asset.file.attached? ? asset.file.content_type : nil,
  140. url: asset.file.attached? ? rails_blob_path(asset.file) : nil,
  141. download_url: brand_brand_asset_path(@brand, asset, format: :download),
  142. created_at: asset.created_at.iso8601,
  143. processed_at: asset.processed_at&.iso8601
  144. }
  145. end
  146. end

app/controllers/brand_guidelines_controller.rb

0.0% lines covered

55 relevant lines. 0 lines covered and 55 lines missed.
    
  1. class BrandGuidelinesController < ApplicationController
  2. before_action :set_brand
  3. before_action :set_brand_guideline, only: [:show, :edit, :update, :destroy]
  4. def index
  5. @guidelines_by_category = @brand.brand_guidelines.active.ordered
  6. .group_by(&:category)
  7. end
  8. def show
  9. end
  10. def new
  11. @brand_guideline = @brand.brand_guidelines.build
  12. end
  13. def create
  14. @brand_guideline = @brand.brand_guidelines.build(brand_guideline_params)
  15. if @brand_guideline.save
  16. redirect_to brand_brand_guidelines_path(@brand),
  17. notice: 'Brand guideline was successfully created.'
  18. else
  19. render :new, status: :unprocessable_entity
  20. end
  21. end
  22. def edit
  23. end
  24. def update
  25. if @brand_guideline.update(brand_guideline_params)
  26. redirect_to brand_brand_guidelines_path(@brand),
  27. notice: 'Brand guideline was successfully updated.'
  28. else
  29. render :edit, status: :unprocessable_entity
  30. end
  31. end
  32. def destroy
  33. @brand_guideline.destroy!
  34. redirect_to brand_brand_guidelines_path(@brand),
  35. notice: 'Brand guideline was successfully destroyed.'
  36. end
  37. private
  38. def set_brand
  39. @brand = current_user.brands.find(params[:brand_id])
  40. end
  41. def set_brand_guideline
  42. @brand_guideline = @brand.brand_guidelines.find(params[:id])
  43. end
  44. def brand_guideline_params
  45. params.require(:brand_guideline).permit(
  46. :rule_type,
  47. :rule_content,
  48. :category,
  49. :priority,
  50. :active,
  51. examples: {},
  52. metadata: {}
  53. )
  54. end
  55. end

app/controllers/brands_controller.rb

0.0% lines covered

68 relevant lines. 0 lines covered and 68 lines missed.
    
  1. class BrandsController < ApplicationController
  2. before_action :set_brand, only: [:show, :edit, :update, :destroy, :compliance_check, :check_content_compliance]
  3. def index
  4. @brands = current_user.brands.active.includes(:brand_assets, :latest_analysis)
  5. end
  6. def show
  7. @latest_analysis = @brand.latest_analysis
  8. @brand_assets = @brand.brand_assets.includes(:file_attachment)
  9. @guidelines = @brand.brand_guidelines.active.ordered
  10. @messaging_framework = @brand.messaging_framework
  11. end
  12. def new
  13. @brand = current_user.brands.build
  14. end
  15. def create
  16. @brand = current_user.brands.build(brand_params)
  17. if @brand.save
  18. redirect_to @brand, notice: 'Brand was successfully created.'
  19. else
  20. render :new, status: :unprocessable_entity
  21. end
  22. end
  23. def edit
  24. end
  25. def update
  26. if @brand.update(brand_params)
  27. redirect_to @brand, notice: 'Brand was successfully updated.'
  28. else
  29. render :edit, status: :unprocessable_entity
  30. end
  31. end
  32. def destroy
  33. @brand.destroy!
  34. redirect_to brands_url, notice: 'Brand was successfully destroyed.'
  35. end
  36. def compliance_check
  37. @compliance_form = ComplianceCheckForm.new
  38. end
  39. def check_content_compliance
  40. content = params[:content]
  41. content_type = params[:content_type] || 'general'
  42. service = Branding::ComplianceService.new(@brand, content, content_type)
  43. result = service.validate_and_suggest
  44. respond_to do |format|
  45. format.json { render json: result }
  46. format.html do
  47. @compliance_result = result
  48. render :compliance_result
  49. end
  50. end
  51. end
  52. private
  53. def set_brand
  54. @brand = current_user.brands.find(params[:id])
  55. end
  56. def brand_params
  57. params.require(:brand).permit(
  58. :name,
  59. :description,
  60. :industry,
  61. :website,
  62. :active,
  63. color_scheme: {},
  64. typography: {},
  65. settings: {}
  66. )
  67. end
  68. end

app/controllers/concerns/activity_trackable.rb

0.0% lines covered

130 relevant lines. 0 lines covered and 130 lines missed.
    
  1. # frozen_string_literal: true
  2. module ActivityTrackable
  3. extend ActiveSupport::Concern
  4. included do
  5. # Track activity for all actions by default
  6. after_action :track_user_activity
  7. end
  8. private
  9. def track_user_activity
  10. return unless should_track_activity?
  11. UserActivity.log_activity(
  12. current_user,
  13. determine_activity_action,
  14. controller_name: controller_name,
  15. action_name: action_name,
  16. resource_type: determine_resource_type,
  17. resource_id: determine_resource_id,
  18. ip_address: request.remote_ip,
  19. user_agent: request.user_agent,
  20. request_params: filtered_params,
  21. metadata: activity_metadata
  22. )
  23. rescue StandardError => e
  24. Rails.logger.error "Failed to track user activity: #{e.message}"
  25. Rails.logger.error e.backtrace.join("\n")
  26. end
  27. def should_track_activity?
  28. # Only track if user is authenticated
  29. return false unless current_user.present?
  30. # Skip tracking for certain controllers/actions
  31. skip_controllers = %w[rails_admin]
  32. skip_actions = %w[show index]
  33. return false if skip_controllers.include?(controller_name)
  34. return false if skip_actions.include?(action_name) && request.get?
  35. true
  36. end
  37. def determine_activity_action
  38. case action_name
  39. when 'create'
  40. UserActivity::ACTIVITY_TYPES[:create]
  41. when 'update', 'edit'
  42. UserActivity::ACTIVITY_TYPES[:update]
  43. when 'destroy'
  44. UserActivity::ACTIVITY_TYPES[:delete]
  45. when 'download'
  46. UserActivity::ACTIVITY_TYPES[:download]
  47. when 'upload'
  48. UserActivity::ACTIVITY_TYPES[:upload]
  49. else
  50. # Map specific controller actions
  51. if controller_name == 'sessions' && action_name == 'create'
  52. UserActivity::ACTIVITY_TYPES[:login]
  53. elsif controller_name == 'sessions' && action_name == 'destroy'
  54. UserActivity::ACTIVITY_TYPES[:logout]
  55. elsif controller_name == 'passwords' && action_name == 'create'
  56. UserActivity::ACTIVITY_TYPES[:password_reset]
  57. elsif controller_name == 'profiles' && action_name == 'update'
  58. UserActivity::ACTIVITY_TYPES[:profile_update]
  59. else
  60. action_name
  61. end
  62. end
  63. end
  64. def determine_resource_type
  65. # Try to infer resource type from controller name
  66. return nil if params[:controller].blank?
  67. controller_parts = params[:controller].split('/')
  68. resource_name = controller_parts.last.singularize.camelize
  69. # Check if it's a valid model
  70. begin
  71. resource_name.constantize
  72. resource_name
  73. rescue NameError
  74. nil
  75. end
  76. end
  77. def determine_resource_id
  78. # Common parameter names for resource IDs
  79. id_params = [:id, :resource_id, "#{controller_name.singularize}_id".to_sym]
  80. id_params.each do |param|
  81. return params[param] if params[param].present?
  82. end
  83. nil
  84. end
  85. def filtered_params
  86. # Filter sensitive parameters
  87. filtered = params.except(
  88. :password,
  89. :password_confirmation,
  90. :token,
  91. :secret,
  92. :api_key,
  93. :access_token,
  94. :refresh_token,
  95. :authenticity_token
  96. )
  97. # Convert to hash and limit size
  98. filtered.to_unsafe_h.slice(*allowed_param_keys).to_json
  99. rescue StandardError
  100. '{}'
  101. end
  102. def allowed_param_keys
  103. # Define which parameters to log
  104. %w[action controller id page per_page search filter sort order]
  105. end
  106. def activity_metadata
  107. {
  108. session_id: session.id,
  109. referer: request.referer,
  110. method: request.method,
  111. path: request.path,
  112. timestamp: Time.current.iso8601
  113. }
  114. end
  115. # Helper method to track specific activities
  116. def track_activity(action, options = {})
  117. return unless current_user.present?
  118. UserActivity.log_activity(
  119. current_user,
  120. action,
  121. options.merge(
  122. controller_name: controller_name,
  123. action_name: action_name,
  124. ip_address: request.remote_ip,
  125. user_agent: request.user_agent
  126. )
  127. )
  128. end
  129. # Track failed login attempts (call this manually in sessions controller)
  130. def track_failed_login(email)
  131. user = User.find_by(email: email)
  132. return unless user
  133. UserActivity.log_activity(
  134. user,
  135. UserActivity::ACTIVITY_TYPES[:failed_login],
  136. controller_name: controller_name,
  137. action_name: action_name,
  138. ip_address: request.remote_ip,
  139. user_agent: request.user_agent,
  140. metadata: { attempted_email: email }
  141. )
  142. end
  143. end

app/controllers/concerns/activity_tracker.rb

0.0% lines covered

106 relevant lines. 0 lines covered and 106 lines missed.
    
  1. module ActivityTracker
  2. extend ActiveSupport::Concern
  3. included do
  4. around_action :track_activity, if: :track_activity?
  5. before_action :set_current_request_context
  6. end
  7. private
  8. def track_activity
  9. return yield unless current_user && track_activity?
  10. start_time = Time.current
  11. # Set request ID for logging correlation
  12. Thread.current[:request_id] = request.request_id
  13. # Log the start of the action
  14. ActivityLogger.log(:debug, "Action started", {
  15. controller: controller_name,
  16. action: action_name,
  17. user_id: current_user.id,
  18. method: request.method
  19. })
  20. yield
  21. # Track successful activities
  22. response_time = Time.current - start_time
  23. log_user_activity(response_time: response_time) if start_time
  24. # Log performance metrics for slow requests
  25. if response_time > 1.0
  26. ActivityLogger.performance('slow_request', "Slow request detected", {
  27. controller: controller_name,
  28. action: action_name,
  29. duration_ms: (response_time * 1000).round,
  30. path: request.path
  31. })
  32. end
  33. rescue => e
  34. # Track failed activities
  35. response_time = start_time ? Time.current - start_time : nil
  36. # Log the error
  37. ActivityLogger.log(:error, "Action failed", {
  38. controller: controller_name,
  39. action: action_name,
  40. error: e.message,
  41. backtrace: e.backtrace.first(5),
  42. duration_ms: response_time ? (response_time * 1000).round : nil
  43. })
  44. log_user_activity(
  45. response_time: response_time,
  46. error: e.message,
  47. response_status: 500
  48. ) if current_user
  49. raise e
  50. ensure
  51. Thread.current[:request_id] = nil
  52. end
  53. def log_user_activity(additional_metadata = {})
  54. return unless current_user && should_log_activity?
  55. metadata = {
  56. params: filtered_params,
  57. response_time: additional_metadata[:response_time],
  58. error: additional_metadata[:error],
  59. request_format: request.format.to_s,
  60. ajax_request: request.xhr?,
  61. ssl: request.ssl?
  62. }.compact
  63. activity = Activity.log_activity(
  64. user: current_user,
  65. action: action_name,
  66. controller: controller_name,
  67. request: request,
  68. response: response,
  69. metadata: metadata
  70. )
  71. # Check for suspicious activity
  72. if activity.persisted?
  73. suspicious = check_suspicious_activity(activity)
  74. # Log security events
  75. if suspicious
  76. ActivityLogger.security('suspicious_activity', "Suspicious activity detected", {
  77. activity_id: activity.id,
  78. reasons: activity.metadata['suspicious_reasons']
  79. })
  80. end
  81. end
  82. rescue => e
  83. Rails.logger.error "Failed to log activity: #{e.message}"
  84. ActivityLogger.log(:error, "Activity logging failed", {
  85. error: e.message,
  86. controller: controller_name,
  87. action: action_name
  88. })
  89. end
  90. def check_suspicious_activity(activity)
  91. SuspiciousActivityDetector.new(activity).check
  92. end
  93. def track_activity?
  94. # Track all actions by default, override in controllers as needed
  95. true
  96. end
  97. def should_log_activity?
  98. # Don't log certain actions to avoid noise
  99. skip_actions = %w[heartbeat health_check]
  100. skip_controllers = %w[rails_admin active_storage]
  101. !skip_actions.include?(action_name) &&
  102. !skip_controllers.include?(controller_name) &&
  103. !request.path.start_with?('/rails/active_storage')
  104. end
  105. def filtered_params
  106. # Remove sensitive parameters
  107. request.filtered_parameters.except("controller", "action", "authenticity_token")
  108. rescue
  109. {}
  110. end
  111. def set_current_request_context
  112. # Set context for Current attributes
  113. Current.request_id = request.request_id
  114. Current.user_agent = request.user_agent
  115. Current.ip_address = request.remote_ip
  116. Current.session_id = session.id if session.loaded?
  117. end
  118. end

app/controllers/concerns/admin_auditable.rb

0.0% lines covered

78 relevant lines. 0 lines covered and 78 lines missed.
    
  1. module AdminAuditable
  2. extend ActiveSupport::Concern
  3. included do
  4. if respond_to?(:after_action)
  5. after_action :log_admin_action, if: :should_audit?
  6. end
  7. end
  8. private
  9. def log_admin_action
  10. return unless current_user && admin_action_performed?
  11. action_name = determine_admin_action
  12. auditable = determine_auditable_resource
  13. changes = determine_changes
  14. AdminAuditLog.log_action(
  15. user: current_user,
  16. action: action_name,
  17. auditable: auditable,
  18. changes: changes,
  19. request: request
  20. )
  21. rescue => e
  22. Rails.logger.error "Failed to log admin action: #{e.message}"
  23. end
  24. def should_audit?
  25. # Only audit if user is admin and we're in the admin area
  26. current_user&.admin? && request.path.start_with?("/admin")
  27. end
  28. def admin_action_performed?
  29. # Check if the request method indicates a change was made
  30. request.post? || request.put? || request.patch? || request.delete?
  31. end
  32. def determine_admin_action
  33. case request.method.downcase
  34. when "post"
  35. params[:action] == "create" ? "created" : "action_performed"
  36. when "put", "patch"
  37. "updated"
  38. when "delete"
  39. "deleted"
  40. else
  41. "viewed"
  42. end
  43. end
  44. def determine_auditable_resource
  45. # Try to determine the resource being acted upon
  46. if defined?(@object) && @object.present?
  47. @object
  48. elsif params[:model_name].present? && params[:id].present?
  49. begin
  50. model_class = params[:model_name].classify.constantize
  51. model_class.find_by(id: params[:id])
  52. rescue
  53. nil
  54. end
  55. end
  56. end
  57. def determine_changes
  58. return nil unless defined?(@object) && @object.present?
  59. if @object.respond_to?(:previous_changes) && @object.previous_changes.any?
  60. # Filter out sensitive fields
  61. @object.previous_changes.except(
  62. "password_digest",
  63. "password",
  64. "password_confirmation",
  65. "session_token",
  66. "reset_token"
  67. )
  68. elsif params[:bulk_ids].present?
  69. { bulk_action: true, affected_ids: params[:bulk_ids] }
  70. else
  71. params.permit!.to_h.except(
  72. :controller,
  73. :action,
  74. :authenticity_token,
  75. :_method,
  76. :utf8,
  77. :password,
  78. :password_confirmation
  79. ).presence
  80. end
  81. end
  82. end

app/controllers/concerns/api_authentication.rb

0.0% lines covered

36 relevant lines. 0 lines covered and 36 lines missed.
    
  1. module ApiAuthentication
  2. extend ActiveSupport::Concern
  3. included do
  4. before_action :authenticate_api_user
  5. end
  6. private
  7. def authenticate_api_user
  8. # Use the existing session-based authentication for API endpoints
  9. unless authenticated?
  10. render_api_authentication_error
  11. return false
  12. end
  13. # Check if user account is active
  14. if current_user.locked?
  15. render_api_account_locked_error
  16. return false
  17. end
  18. true
  19. end
  20. def render_api_authentication_error
  21. render json: {
  22. success: false,
  23. message: 'Authentication required',
  24. code: 'AUTHENTICATION_REQUIRED'
  25. }, status: :unauthorized
  26. end
  27. def render_api_account_locked_error
  28. render json: {
  29. success: false,
  30. message: 'Account is locked',
  31. code: 'ACCOUNT_LOCKED',
  32. details: current_user.lock_reason
  33. }, status: :forbidden
  34. end
  35. # Override parent class methods to return JSON instead of redirects
  36. def request_authentication
  37. render_api_authentication_error
  38. end
  39. end

app/controllers/concerns/api_error_handling.rb

0.0% lines covered

50 relevant lines. 0 lines covered and 50 lines missed.
    
  1. module ApiErrorHandling
  2. extend ActiveSupport::Concern
  3. included do
  4. rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
  5. rescue_from ActiveRecord::RecordInvalid, with: :handle_validation_error
  6. rescue_from ActionController::ParameterMissing, with: :handle_parameter_missing
  7. rescue_from Pundit::NotAuthorizedError, with: :handle_unauthorized
  8. rescue_from StandardError, with: :handle_internal_error
  9. end
  10. private
  11. def handle_not_found(exception)
  12. render_error(
  13. message: 'Resource not found',
  14. status: :not_found,
  15. code: 'RESOURCE_NOT_FOUND'
  16. )
  17. end
  18. def handle_validation_error(exception)
  19. render_error(
  20. message: 'Validation failed',
  21. errors: exception.record.errors.as_json,
  22. status: :unprocessable_entity,
  23. code: 'VALIDATION_ERROR'
  24. )
  25. end
  26. def handle_parameter_missing(exception)
  27. render_error(
  28. message: "Required parameter missing: #{exception.param}",
  29. status: :bad_request,
  30. code: 'PARAMETER_MISSING'
  31. )
  32. end
  33. def handle_unauthorized(exception)
  34. render_error(
  35. message: 'Access denied',
  36. status: :forbidden,
  37. code: 'ACCESS_DENIED'
  38. )
  39. end
  40. def handle_internal_error(exception)
  41. # Log the error for debugging
  42. Rails.logger.error "API Error: #{exception.class} - #{exception.message}"
  43. Rails.logger.error exception.backtrace.join("\n") if Rails.env.development?
  44. # Don't expose internal error details in production
  45. message = Rails.env.production? ? 'Internal server error' : exception.message
  46. render_error(
  47. message: message,
  48. status: :internal_server_error,
  49. code: 'INTERNAL_ERROR'
  50. )
  51. end
  52. end

app/controllers/concerns/api_pagination.rb

0.0% lines covered

40 relevant lines. 0 lines covered and 40 lines missed.
    
  1. module ApiPagination
  2. extend ActiveSupport::Concern
  3. DEFAULT_PAGE_SIZE = 25
  4. MAX_PAGE_SIZE = 100
  5. private
  6. def paginate_collection(collection)
  7. page = [params[:page].to_i, 1].max
  8. per_page = [[params[:per_page].to_i, DEFAULT_PAGE_SIZE].max, MAX_PAGE_SIZE].min
  9. offset = (page - 1) * per_page
  10. total_count = collection.count
  11. total_pages = (total_count.to_f / per_page).ceil
  12. paginated_collection = collection.limit(per_page).offset(offset)
  13. {
  14. collection: paginated_collection,
  15. meta: {
  16. pagination: {
  17. current_page: page,
  18. per_page: per_page,
  19. total_count: total_count,
  20. total_pages: total_pages,
  21. has_next_page: page < total_pages,
  22. has_previous_page: page > 1
  23. }
  24. }
  25. }
  26. end
  27. def paginate_and_render(collection, serializer: nil, **options)
  28. result = paginate_collection(collection)
  29. data = if serializer
  30. result[:collection].map { |item| serializer.call(item) }
  31. else
  32. result[:collection]
  33. end
  34. render_success(
  35. data: data,
  36. meta: result[:meta],
  37. **options
  38. )
  39. end
  40. end

app/controllers/concerns/authentication.rb

0.0% lines covered

81 relevant lines. 0 lines covered and 81 lines missed.
    
  1. module Authentication
  2. extend ActiveSupport::Concern
  3. included do
  4. before_action :require_authentication
  5. helper_method :authenticated?, :current_user
  6. end
  7. class_methods do
  8. def allow_unauthenticated_access(**options)
  9. skip_before_action :require_authentication, **options
  10. end
  11. end
  12. private
  13. def authenticated?
  14. resume_session
  15. end
  16. def current_user
  17. Current.session&.user
  18. end
  19. def require_authentication
  20. resume_session || request_authentication
  21. end
  22. def resume_session
  23. Current.session ||= find_session_by_cookie
  24. if Current.session
  25. if Current.session.expired? || Current.session.inactive?
  26. terminate_session
  27. false
  28. elsif Current.session.user.locked?
  29. terminate_session
  30. redirect_to new_session_path, alert: "Your account has been locked: #{Current.session.user.lock_reason}"
  31. false
  32. else
  33. Current.session.touch_activity!
  34. true
  35. end
  36. else
  37. false
  38. end
  39. end
  40. def find_session_by_cookie
  41. Session.active.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
  42. end
  43. def request_authentication
  44. session[:return_to_after_authenticating] = request.url
  45. redirect_to new_session_path
  46. end
  47. def after_authentication_url
  48. session.delete(:return_to_after_authenticating) || root_url
  49. end
  50. def start_new_session_for(user, remember_me: false)
  51. session_timeout = remember_me ? 30.days : Session::SESSION_TIMEOUT
  52. user.sessions.create!(
  53. user_agent: request.user_agent,
  54. ip_address: request.remote_ip,
  55. expires_at: session_timeout.from_now
  56. ).tap do |session|
  57. Current.session = session
  58. if remember_me
  59. cookies.signed.permanent[:session_id] = {
  60. value: session.id,
  61. httponly: true,
  62. same_site: :lax,
  63. secure: Rails.env.production?
  64. }
  65. else
  66. cookies.signed[:session_id] = {
  67. value: session.id,
  68. httponly: true,
  69. same_site: :lax,
  70. secure: Rails.env.production?,
  71. expires: session_timeout.from_now
  72. }
  73. end
  74. end
  75. end
  76. def terminate_session
  77. Current.session.destroy if Current.session
  78. cookies.delete(:session_id)
  79. Current.session = nil
  80. end
  81. end

app/controllers/concerns/rails_admin_auditable.rb

0.0% lines covered

66 relevant lines. 0 lines covered and 66 lines missed.
    
  1. module RailsAdminAuditable
  2. extend ActiveSupport::Concern
  3. included do
  4. after_action :log_admin_action, if: :admin_action_performed?
  5. end
  6. private
  7. def admin_action_performed?
  8. # Only log write actions in admin panel
  9. controller_name == 'rails_admin/main' &&
  10. %w[create update destroy bulk_delete].include?(action_name)
  11. end
  12. def log_admin_action
  13. return unless current_user
  14. action = determine_admin_action
  15. auditable = determine_auditable
  16. changes = determine_changes
  17. AdminAuditLog.log_action(
  18. user: current_user,
  19. action: action,
  20. auditable: auditable,
  21. changes: changes,
  22. request: request
  23. )
  24. rescue StandardError => e
  25. Rails.logger.error "Failed to log admin action: #{e.message}"
  26. end
  27. def determine_admin_action
  28. case action_name
  29. when 'create'
  30. "created_#{@model_config.abstract_model.model.name.underscore}"
  31. when 'update'
  32. "updated_#{@model_config.abstract_model.model.name.underscore}"
  33. when 'destroy'
  34. "deleted_#{@model_config.abstract_model.model.name.underscore}"
  35. when 'bulk_delete'
  36. "bulk_deleted_#{@model_config.abstract_model.model.name.underscore.pluralize}"
  37. else
  38. action_name
  39. end
  40. end
  41. def determine_auditable
  42. case action_name
  43. when 'create', 'update'
  44. @object
  45. when 'destroy'
  46. # Object might be destroyed, so we log the class and ID
  47. { type: @model_config.abstract_model.model.name, id: params[:id] }
  48. when 'bulk_delete'
  49. { type: @model_config.abstract_model.model.name, ids: params[:bulk_ids] }
  50. else
  51. nil
  52. end
  53. end
  54. def determine_changes
  55. case action_name
  56. when 'create'
  57. @object.attributes
  58. when 'update'
  59. @object.previous_changes.except('updated_at')
  60. when 'destroy'
  61. { deleted_record: @object.attributes }
  62. when 'bulk_delete'
  63. { deleted_count: params[:bulk_ids]&.size || 0 }
  64. else
  65. nil
  66. end
  67. end
  68. end

app/controllers/home_controller.rb

0.0% lines covered

5 relevant lines. 0 lines covered and 5 lines missed.
    
  1. class HomeController < ApplicationController
  2. allow_unauthenticated_access
  3. def index
  4. end
  5. end

app/controllers/journey_suggestions_controller.rb

0.0% lines covered

405 relevant lines. 0 lines covered and 405 lines missed.
    
  1. class JourneySuggestionsController < ApplicationController
  2. before_action :set_journey
  3. before_action :set_current_step, only: [:index, :for_step]
  4. before_action :authorize_journey_access
  5. # GET /journeys/:journey_id/suggestions
  6. def index
  7. filters = build_filters_from_params
  8. begin
  9. engine = JourneySuggestionEngine.new(
  10. journey: @journey,
  11. user: current_user,
  12. current_step: @current_step,
  13. provider: suggestion_provider
  14. )
  15. @suggestions = engine.generate_suggestions(filters)
  16. @feedback_insights = engine.get_feedback_insights
  17. respond_to do |format|
  18. format.json {
  19. render json: {
  20. success: true,
  21. data: {
  22. suggestions: @suggestions,
  23. feedback_insights: @feedback_insights,
  24. journey_context: journey_context_summary,
  25. filters_applied: filters,
  26. provider: suggestion_provider,
  27. cached: Rails.cache.exist?(cache_key_for_request)
  28. },
  29. meta: {
  30. total_suggestions: @suggestions.length,
  31. generated_at: Time.current,
  32. expires_at: 1.hour.from_now
  33. }
  34. }
  35. }
  36. format.html { render :index }
  37. end
  38. rescue => e
  39. Rails.logger.error "Suggestion generation failed: #{e.message}"
  40. Rails.logger.error e.backtrace.join("\n")
  41. render json: {
  42. success: false,
  43. error: {
  44. message: "Failed to generate suggestions",
  45. details: Rails.env.development? ? e.message : "Internal server error"
  46. }
  47. }, status: :internal_server_error
  48. end
  49. end
  50. # GET /journeys/:journey_id/suggestions/for_stage/:stage
  51. def for_stage
  52. stage = params[:stage]
  53. unless Journey::STAGES.include?(stage)
  54. return render json: {
  55. success: false,
  56. error: { message: "Invalid stage: #{stage}" }
  57. }, status: :bad_request
  58. end
  59. filters = build_filters_from_params.merge(stage: stage)
  60. begin
  61. engine = JourneySuggestionEngine.new(
  62. journey: @journey,
  63. user: current_user,
  64. provider: suggestion_provider
  65. )
  66. @suggestions = engine.suggest_for_stage(stage, filters)
  67. render json: {
  68. success: true,
  69. data: {
  70. suggestions: @suggestions,
  71. stage: stage,
  72. filters_applied: filters,
  73. provider: suggestion_provider
  74. },
  75. meta: {
  76. total_suggestions: @suggestions.length,
  77. generated_at: Time.current
  78. }
  79. }
  80. rescue => e
  81. Rails.logger.error "Stage suggestion generation failed: #{e.message}"
  82. render json: {
  83. success: false,
  84. error: {
  85. message: "Failed to generate stage suggestions",
  86. details: Rails.env.development? ? e.message : "Internal server error"
  87. }
  88. }, status: :internal_server_error
  89. end
  90. end
  91. # GET /journeys/:journey_id/suggestions/for_step/:step_id
  92. def for_step
  93. step = @journey.journey_steps.find(params[:step_id])
  94. filters = build_filters_from_params
  95. begin
  96. engine = JourneySuggestionEngine.new(
  97. journey: @journey,
  98. user: current_user,
  99. current_step: step,
  100. provider: suggestion_provider
  101. )
  102. @suggestions = engine.generate_suggestions(filters)
  103. render json: {
  104. success: true,
  105. data: {
  106. suggestions: @suggestions,
  107. current_step: step.as_json(only: [:id, :name, :stage, :content_type, :channel]),
  108. filters_applied: filters,
  109. provider: suggestion_provider
  110. },
  111. meta: {
  112. total_suggestions: @suggestions.length,
  113. generated_at: Time.current
  114. }
  115. }
  116. rescue ActiveRecord::RecordNotFound
  117. render json: {
  118. success: false,
  119. error: { message: "Journey step not found" }
  120. }, status: :not_found
  121. rescue => e
  122. Rails.logger.error "Step suggestion generation failed: #{e.message}"
  123. render json: {
  124. success: false,
  125. error: {
  126. message: "Failed to generate step suggestions",
  127. details: Rails.env.development? ? e.message : "Internal server error"
  128. }
  129. }, status: :internal_server_error
  130. end
  131. end
  132. # POST /journeys/:journey_id/suggestions/feedback
  133. def create_feedback
  134. suggestion_data = params.require(:suggestion)
  135. feedback_params = params.require(:feedback)
  136. begin
  137. engine = JourneySuggestionEngine.new(
  138. journey: @journey,
  139. user: current_user,
  140. current_step: @current_step,
  141. provider: suggestion_provider
  142. )
  143. feedback = engine.record_feedback(
  144. suggestion_data.to_h,
  145. feedback_params[:feedback_type],
  146. rating: feedback_params[:rating],
  147. selected: feedback_params[:selected],
  148. context: feedback_params[:context]
  149. )
  150. if feedback.persisted?
  151. render json: {
  152. success: true,
  153. data: {
  154. feedback_id: feedback.id,
  155. message: "Feedback recorded successfully"
  156. }
  157. }, status: :created
  158. else
  159. render json: {
  160. success: false,
  161. error: {
  162. message: "Failed to record feedback",
  163. details: feedback.errors.full_messages
  164. }
  165. }, status: :unprocessable_entity
  166. end
  167. rescue => e
  168. Rails.logger.error "Feedback recording failed: #{e.message}"
  169. render json: {
  170. success: false,
  171. error: {
  172. message: "Failed to record feedback",
  173. details: Rails.env.development? ? e.message : "Internal server error"
  174. }
  175. }, status: :internal_server_error
  176. end
  177. end
  178. # GET /journeys/:journey_id/suggestions/insights
  179. def insights
  180. @insights = @journey.journey_insights
  181. .active
  182. .order(calculated_at: :desc)
  183. .limit(10)
  184. @feedback_analytics = calculate_feedback_analytics
  185. @suggestion_performance = calculate_suggestion_performance
  186. respond_to do |format|
  187. format.json {
  188. render json: {
  189. success: true,
  190. data: {
  191. insights: @insights.map(&:to_summary),
  192. feedback_analytics: @feedback_analytics,
  193. suggestion_performance: @suggestion_performance,
  194. journey_summary: journey_context_summary
  195. },
  196. meta: {
  197. total_insights: @insights.length,
  198. generated_at: Time.current
  199. }
  200. }
  201. }
  202. format.html { render :insights }
  203. end
  204. end
  205. # GET /journeys/:journey_id/suggestions/analytics
  206. def analytics
  207. date_range = params[:date_range] || '30_days'
  208. days = case date_range
  209. when '7_days' then 7
  210. when '30_days' then 30
  211. when '90_days' then 90
  212. else 30
  213. end
  214. @analytics = {
  215. feedback_trends: calculate_feedback_trends(days),
  216. selection_rates: calculate_selection_rates(days),
  217. performance_by_type: calculate_performance_by_type(days),
  218. ai_provider_comparison: calculate_provider_comparison(days),
  219. improvement_opportunities: identify_improvement_opportunities
  220. }
  221. render json: {
  222. success: true,
  223. data: @analytics,
  224. meta: {
  225. date_range: date_range,
  226. days_analyzed: days,
  227. generated_at: Time.current
  228. }
  229. }
  230. end
  231. # DELETE /journeys/:journey_id/suggestions/cache
  232. def clear_cache
  233. cache_pattern = "journey_suggestions:#{@journey.id}:*"
  234. Rails.cache.delete_matched(cache_pattern)
  235. render json: {
  236. success: true,
  237. message: "Cache cleared for journey suggestions"
  238. }
  239. end
  240. private
  241. def set_journey
  242. @journey = current_user.journeys.find(params[:journey_id])
  243. rescue ActiveRecord::RecordNotFound
  244. render json: {
  245. success: false,
  246. error: { message: "Journey not found" }
  247. }, status: :not_found
  248. end
  249. def set_current_step
  250. return unless params[:current_step_id]
  251. @current_step = @journey.journey_steps.find(params[:current_step_id])
  252. rescue ActiveRecord::RecordNotFound
  253. @current_step = nil
  254. end
  255. def authorize_journey_access
  256. unless @journey && @journey.user == current_user
  257. render json: {
  258. success: false,
  259. error: { message: "Unauthorized access to journey" }
  260. }, status: :forbidden
  261. end
  262. end
  263. def build_filters_from_params
  264. filters = {}
  265. filters[:stage] = params[:stage] if params[:stage].present?
  266. filters[:content_type] = params[:content_type] if params[:content_type].present?
  267. filters[:channel] = params[:channel] if params[:channel].present?
  268. filters[:max_suggestions] = params[:max_suggestions].to_i if params[:max_suggestions].present?
  269. filters[:min_confidence] = params[:min_confidence].to_f if params[:min_confidence].present?
  270. filters
  271. end
  272. def suggestion_provider
  273. provider = params[:provider] || 'openai'
  274. provider.to_sym if JourneySuggestionEngine::PROVIDERS.key?(provider.to_sym)
  275. end
  276. def journey_context_summary
  277. {
  278. id: @journey.id,
  279. name: @journey.name,
  280. status: @journey.status,
  281. campaign_type: @journey.campaign_type,
  282. total_steps: @journey.total_steps,
  283. stages_coverage: @journey.steps_by_stage,
  284. current_step: @current_step&.as_json(only: [:id, :name, :stage, :position])
  285. }
  286. end
  287. def calculate_feedback_analytics
  288. return {} unless @journey.suggestion_feedbacks.any?
  289. {
  290. average_ratings: @journey.suggestion_feedbacks.average_rating_by_type,
  291. total_feedback_count: @journey.suggestion_feedbacks.count,
  292. selection_rate: calculate_overall_selection_rate,
  293. feedback_distribution: @journey.suggestion_feedbacks.group(:feedback_type).count,
  294. recent_trends: @journey.suggestion_feedbacks.feedback_trends(7)
  295. }
  296. end
  297. def calculate_suggestion_performance
  298. feedbacks = @journey.suggestion_feedbacks.includes(:journey_step)
  299. {
  300. top_performing_content_types: feedbacks.selection_rate_by_content_type,
  301. top_performing_stages: feedbacks.selection_rate_by_stage,
  302. most_selected_suggestions: feedbacks.top_performing_suggestions(5),
  303. provider_performance: calculate_provider_feedback_performance
  304. }
  305. end
  306. def calculate_overall_selection_rate
  307. total_feedbacks = @journey.suggestion_feedbacks.count
  308. return 0 if total_feedbacks.zero?
  309. selected_count = @journey.suggestion_feedbacks.selected.count
  310. (selected_count.to_f / total_feedbacks * 100).round(2)
  311. end
  312. def calculate_feedback_trends(days)
  313. @journey.suggestion_feedbacks
  314. .where('created_at >= ?', days.days.ago)
  315. .group_by_day(:created_at)
  316. .group(:feedback_type)
  317. .average(:rating)
  318. end
  319. def calculate_selection_rates(days)
  320. feedbacks = @journey.suggestion_feedbacks.where('created_at >= ?', days.days.ago)
  321. {
  322. overall: calculate_selection_rate_for_feedbacks(feedbacks),
  323. by_content_type: feedbacks.selection_rate_by_content_type,
  324. by_stage: feedbacks.selection_rate_by_stage
  325. }
  326. end
  327. def calculate_performance_by_type(days)
  328. feedbacks = @journey.suggestion_feedbacks.where('created_at >= ?', days.days.ago)
  329. JourneySuggestionEngine::FEEDBACK_TYPES.map do |feedback_type|
  330. type_feedbacks = feedbacks.by_feedback_type(feedback_type)
  331. {
  332. feedback_type: feedback_type,
  333. average_rating: type_feedbacks.average(:rating)&.round(2),
  334. total_count: type_feedbacks.count,
  335. positive_count: type_feedbacks.positive.count,
  336. negative_count: type_feedbacks.negative.count
  337. }
  338. end
  339. end
  340. def calculate_provider_comparison(days)
  341. feedbacks = @journey.suggestion_feedbacks.where('created_at >= ?', days.days.ago)
  342. provider_data = {}
  343. feedbacks.group_by { |f| f.ai_provider }.each do |provider, provider_feedbacks|
  344. provider_data[provider] = {
  345. total_suggestions: provider_feedbacks.count,
  346. average_rating: provider_feedbacks.map(&:rating).compact.sum.to_f / provider_feedbacks.count,
  347. selection_rate: calculate_selection_rate_for_feedbacks(provider_feedbacks),
  348. response_time: nil # Would be tracked separately
  349. }
  350. end
  351. provider_data
  352. end
  353. def identify_improvement_opportunities
  354. opportunities = []
  355. # Low-rated content types
  356. low_performing_content = @journey.suggestion_feedbacks
  357. .joins(:journey_step)
  358. .group('journey_steps.content_type')
  359. .having('AVG(rating) < ?', 3.0)
  360. .average(:rating)
  361. low_performing_content.each do |content_type, avg_rating|
  362. opportunities << {
  363. type: 'content_improvement',
  364. content_type: content_type,
  365. current_rating: avg_rating.round(2),
  366. recommendation: "Improve #{content_type} suggestions - currently underperforming"
  367. }
  368. end
  369. # Underrepresented stages
  370. stage_coverage = @journey.steps_by_stage
  371. total_steps = @journey.total_steps
  372. Journey::STAGES.each do |stage|
  373. stage_count = stage_coverage[stage] || 0
  374. if stage_count < (total_steps * 0.1) # Less than 10% representation
  375. opportunities << {
  376. type: 'stage_coverage',
  377. stage: stage,
  378. current_count: stage_count,
  379. recommendation: "Consider adding more #{stage} stage steps to balance the journey"
  380. }
  381. end
  382. end
  383. opportunities
  384. end
  385. def calculate_provider_feedback_performance
  386. @journey.suggestion_feedbacks
  387. .group_by { |f| f.ai_provider }
  388. .transform_values do |feedbacks|
  389. {
  390. count: feedbacks.length,
  391. avg_rating: feedbacks.map(&:rating).compact.sum.to_f / feedbacks.length,
  392. selection_rate: calculate_selection_rate_for_feedbacks(feedbacks)
  393. }
  394. end
  395. end
  396. def calculate_selection_rate_for_feedbacks(feedbacks)
  397. return 0 if feedbacks.empty?
  398. selected_count = feedbacks.count { |f| f.selected? }
  399. (selected_count.to_f / feedbacks.length * 100).round(2)
  400. end
  401. def cache_key_for_request
  402. filters = build_filters_from_params
  403. key_parts = [
  404. "journey_suggestions",
  405. @journey.id,
  406. @journey.updated_at.to_i,
  407. @current_step&.id,
  408. current_user.id,
  409. suggestion_provider,
  410. Digest::MD5.hexdigest(filters.to_json)
  411. ]
  412. key_parts.join(":")
  413. end
  414. end

app/controllers/journey_templates_controller.rb

0.0% lines covered

127 relevant lines. 0 lines covered and 127 lines missed.
    
  1. class JourneyTemplatesController < ApplicationController
  2. before_action :require_authentication
  3. before_action :set_journey_template, only: [:show, :edit, :update, :destroy, :clone, :use_template, :builder, :builder_react]
  4. def index
  5. @templates = JourneyTemplate.active.includes(:journeys)
  6. # Filter by category if specified
  7. @templates = @templates.by_category(params[:category]) if params[:category].present?
  8. # Filter by campaign type if specified
  9. @templates = @templates.by_campaign_type(params[:campaign_type]) if params[:campaign_type].present?
  10. # Search by name or description
  11. if params[:search].present?
  12. @templates = @templates.where(
  13. "name ILIKE ? OR description ILIKE ?",
  14. "%#{params[:search]}%", "%#{params[:search]}%"
  15. )
  16. end
  17. # Sort templates
  18. case params[:sort]
  19. when 'popular'
  20. @templates = @templates.popular
  21. when 'recent'
  22. @templates = @templates.recent
  23. else
  24. @templates = @templates.order(:name)
  25. end
  26. @categories = JourneyTemplate::CATEGORIES
  27. @campaign_types = Journey::CAMPAIGN_TYPES
  28. end
  29. def show
  30. @preview_steps = @template.preview_steps
  31. @stages_covered = @template.stages_covered
  32. @channels_used = @template.channels_used
  33. @content_types = @template.content_types_included
  34. end
  35. def new
  36. @template = JourneyTemplate.new
  37. end
  38. def create
  39. @template = JourneyTemplate.new(template_params)
  40. if @template.save
  41. respond_to do |format|
  42. format.html { redirect_to @template, notice: 'Journey template was successfully created.' }
  43. format.json { render json: @template, status: :created }
  44. end
  45. else
  46. respond_to do |format|
  47. format.html { render :new, status: :unprocessable_entity }
  48. format.json { render json: { errors: @template.errors }, status: :unprocessable_entity }
  49. end
  50. end
  51. end
  52. def edit
  53. end
  54. def update
  55. if @template.update(template_params)
  56. respond_to do |format|
  57. format.html { redirect_to @template, notice: 'Journey template was successfully updated.' }
  58. format.json { render json: @template }
  59. end
  60. else
  61. respond_to do |format|
  62. format.html { render :edit, status: :unprocessable_entity }
  63. format.json { render json: { errors: @template.errors }, status: :unprocessable_entity }
  64. end
  65. end
  66. end
  67. def destroy
  68. @template.update!(is_active: false)
  69. redirect_to journey_templates_path, notice: 'Journey template was deactivated.'
  70. end
  71. def clone
  72. new_template = @template.dup
  73. new_template.name = "#{@template.name} (Copy)"
  74. new_template.usage_count = 0
  75. new_template.is_active = true
  76. if new_template.save
  77. redirect_to edit_journey_template_path(new_template),
  78. notice: 'Template cloned successfully. You can now customize it.'
  79. else
  80. redirect_to @template, alert: 'Failed to clone template.'
  81. end
  82. end
  83. def use_template
  84. journey = @template.create_journey_for_user(
  85. current_user,
  86. journey_params_for_template
  87. )
  88. if journey.persisted?
  89. redirect_to journey_path(journey),
  90. notice: 'Journey created from template successfully!'
  91. else
  92. redirect_to @template,
  93. alert: "Failed to create journey: #{journey.errors.full_messages.join(', ')}"
  94. end
  95. end
  96. def builder
  97. # Visual journey builder interface
  98. @template ||= JourneyTemplate.new
  99. @existing_steps = @template.template_data&.dig('steps') || []
  100. @stages = ['awareness', 'consideration', 'conversion', 'retention']
  101. @step_types = JourneyStep::STEP_TYPES
  102. end
  103. def builder_react
  104. # React-based visual journey builder interface
  105. @template ||= JourneyTemplate.new
  106. # Prepare data for React component
  107. @journey_data = {
  108. id: @template.id,
  109. name: @template.name || 'New Journey',
  110. description: @template.description || '',
  111. steps: @template.steps_data || [],
  112. connections: @template.connections_data || [],
  113. status: @template.published? ? 'published' : 'draft'
  114. }
  115. end
  116. private
  117. def set_journey_template
  118. if params[:id] == 'new'
  119. @template = JourneyTemplate.new
  120. else
  121. @template = JourneyTemplate.find(params[:id])
  122. end
  123. end
  124. def template_params
  125. params.require(:journey_template).permit(
  126. :name, :description, :category, :campaign_type, :difficulty_level,
  127. :estimated_duration_days, :is_active, :template_data, :status,
  128. steps_data: [], connections_data: []
  129. )
  130. end
  131. def journey_params_for_template
  132. params.permit(:name, :description, :target_audience, :goals, :brand_id)
  133. end
  134. end

app/controllers/messaging_frameworks_controller.rb

0.0% lines covered

306 relevant lines. 0 lines covered and 306 lines missed.
    
  1. class MessagingFrameworksController < ApplicationController
  2. before_action :set_brand
  3. before_action :set_messaging_framework
  4. def show
  5. respond_to do |format|
  6. format.html
  7. format.json { render json: framework_json }
  8. end
  9. end
  10. def edit
  11. end
  12. def update
  13. respond_to do |format|
  14. if @messaging_framework.update(messaging_framework_params)
  15. format.html { redirect_to brand_messaging_framework_path(@brand), notice: 'Messaging framework was successfully updated.' }
  16. format.json { render json: { success: true, messaging_framework: framework_json } }
  17. else
  18. format.html { render :edit, status: :unprocessable_entity }
  19. format.json { render json: { success: false, errors: @messaging_framework.errors.full_messages }, status: :unprocessable_entity }
  20. end
  21. end
  22. end
  23. # AJAX Actions for specific updates
  24. def update_key_messages
  25. if @messaging_framework.update(key_messages: params[:key_messages])
  26. render json: { success: true, key_messages: @messaging_framework.key_messages }
  27. else
  28. render json: { success: false, errors: @messaging_framework.errors.full_messages }, status: :unprocessable_entity
  29. end
  30. end
  31. def update_value_propositions
  32. if @messaging_framework.update(value_propositions: params[:value_propositions])
  33. render json: { success: true, value_propositions: @messaging_framework.value_propositions }
  34. else
  35. render json: { success: false, errors: @messaging_framework.errors.full_messages }, status: :unprocessable_entity
  36. end
  37. end
  38. def update_terminology
  39. if @messaging_framework.update(terminology: params[:terminology])
  40. render json: { success: true, terminology: @messaging_framework.terminology }
  41. else
  42. render json: { success: false, errors: @messaging_framework.errors.full_messages }, status: :unprocessable_entity
  43. end
  44. end
  45. def update_approved_phrases
  46. if @messaging_framework.update(approved_phrases: params[:approved_phrases])
  47. render json: { success: true, approved_phrases: @messaging_framework.approved_phrases }
  48. else
  49. render json: { success: false, errors: @messaging_framework.errors.full_messages }, status: :unprocessable_entity
  50. end
  51. end
  52. def update_banned_words
  53. if @messaging_framework.update(banned_words: params[:banned_words])
  54. render json: { success: true, banned_words: @messaging_framework.banned_words }
  55. else
  56. render json: { success: false, errors: @messaging_framework.errors.full_messages }, status: :unprocessable_entity
  57. end
  58. end
  59. def update_tone_attributes
  60. if @messaging_framework.update(tone_attributes: params[:tone_attributes])
  61. render json: { success: true, tone_attributes: @messaging_framework.tone_attributes }
  62. else
  63. render json: { success: false, errors: @messaging_framework.errors.full_messages }, status: :unprocessable_entity
  64. end
  65. end
  66. def validate_content
  67. content = params[:content]
  68. validation_results = {
  69. banned_words: @messaging_framework.get_banned_words_in_text(content),
  70. contains_banned: @messaging_framework.contains_banned_words?(content),
  71. tone_match: analyze_tone_match(content),
  72. approved_phrases_used: find_approved_phrases_in_text(content)
  73. }
  74. render json: validation_results
  75. end
  76. def export
  77. respond_to do |format|
  78. format.json { render json: @messaging_framework.to_json }
  79. format.csv { send_data generate_csv, filename: "messaging-framework-#{@brand.name.parameterize}-#{Date.today}.csv" }
  80. end
  81. end
  82. def import
  83. if params[:file].present?
  84. result = import_framework_data(params[:file])
  85. if result[:success]
  86. render json: { success: true, message: 'Framework imported successfully' }
  87. else
  88. render json: { success: false, errors: result[:errors] }, status: :unprocessable_entity
  89. end
  90. else
  91. render json: { success: false, errors: ['No file uploaded'] }, status: :unprocessable_entity
  92. end
  93. end
  94. def ai_suggestions
  95. content_type = params[:content_type]
  96. current_content = params[:current_content]
  97. suggestions = generate_ai_suggestions(content_type, current_content)
  98. render json: { suggestions: suggestions }
  99. end
  100. def reorder_key_messages
  101. category = params[:category]
  102. ordered_ids = params[:ordered_ids]
  103. if @messaging_framework.key_messages[category]
  104. reordered_messages = ordered_ids.map do |id|
  105. @messaging_framework.key_messages[category][id.to_i]
  106. end.compact
  107. @messaging_framework.key_messages[category] = reordered_messages
  108. if @messaging_framework.save
  109. render json: { success: true, key_messages: @messaging_framework.key_messages }
  110. else
  111. render json: { success: false, errors: @messaging_framework.errors.full_messages }, status: :unprocessable_entity
  112. end
  113. else
  114. render json: { success: false, errors: ['Category not found'] }, status: :not_found
  115. end
  116. end
  117. def reorder_value_propositions
  118. proposition_type = params[:proposition_type]
  119. ordered_ids = params[:ordered_ids]
  120. if @messaging_framework.value_propositions[proposition_type]
  121. reordered_props = ordered_ids.map do |id|
  122. @messaging_framework.value_propositions[proposition_type][id.to_i]
  123. end.compact
  124. @messaging_framework.value_propositions[proposition_type] = reordered_props
  125. if @messaging_framework.save
  126. render json: { success: true, value_propositions: @messaging_framework.value_propositions }
  127. else
  128. render json: { success: false, errors: @messaging_framework.errors.full_messages }, status: :unprocessable_entity
  129. end
  130. else
  131. render json: { success: false, errors: ['Proposition type not found'] }, status: :not_found
  132. end
  133. end
  134. def add_key_message
  135. category = params[:category]
  136. message = params[:message]
  137. @messaging_framework.key_messages ||= {}
  138. @messaging_framework.key_messages[category] ||= []
  139. @messaging_framework.key_messages[category] << message
  140. if @messaging_framework.save
  141. render json: { success: true, key_messages: @messaging_framework.key_messages }
  142. else
  143. render json: { success: false, errors: @messaging_framework.errors.full_messages }, status: :unprocessable_entity
  144. end
  145. end
  146. def remove_key_message
  147. category = params[:category]
  148. index = params[:index].to_i
  149. if @messaging_framework.key_messages[category]
  150. @messaging_framework.key_messages[category].delete_at(index)
  151. if @messaging_framework.save
  152. render json: { success: true, key_messages: @messaging_framework.key_messages }
  153. else
  154. render json: { success: false, errors: @messaging_framework.errors.full_messages }, status: :unprocessable_entity
  155. end
  156. else
  157. render json: { success: false, errors: ['Category not found'] }, status: :not_found
  158. end
  159. end
  160. def add_value_proposition
  161. proposition_type = params[:proposition_type]
  162. proposition = params[:proposition]
  163. @messaging_framework.value_propositions ||= {}
  164. @messaging_framework.value_propositions[proposition_type] ||= []
  165. @messaging_framework.value_propositions[proposition_type] << proposition
  166. if @messaging_framework.save
  167. render json: { success: true, value_propositions: @messaging_framework.value_propositions }
  168. else
  169. render json: { success: false, errors: @messaging_framework.errors.full_messages }, status: :unprocessable_entity
  170. end
  171. end
  172. def remove_value_proposition
  173. proposition_type = params[:proposition_type]
  174. index = params[:index].to_i
  175. if @messaging_framework.value_propositions[proposition_type]
  176. @messaging_framework.value_propositions[proposition_type].delete_at(index)
  177. if @messaging_framework.save
  178. render json: { success: true, value_propositions: @messaging_framework.value_propositions }
  179. else
  180. render json: { success: false, errors: @messaging_framework.errors.full_messages }, status: :unprocessable_entity
  181. end
  182. else
  183. render json: { success: false, errors: ['Proposition type not found'] }, status: :not_found
  184. end
  185. end
  186. def search_approved_phrases
  187. query = params[:query].to_s.downcase
  188. phrases = @messaging_framework.approved_phrases || []
  189. filtered_phrases = if query.present?
  190. phrases.select { |phrase| phrase.downcase.include?(query) }
  191. else
  192. phrases
  193. end
  194. render json: { phrases: filtered_phrases }
  195. end
  196. private
  197. def set_brand
  198. @brand = current_user.brands.find(params[:brand_id])
  199. end
  200. def set_messaging_framework
  201. @messaging_framework = @brand.messaging_framework || @brand.create_messaging_framework!
  202. end
  203. def messaging_framework_params
  204. params.require(:messaging_framework).permit(
  205. :tagline,
  206. :mission_statement,
  207. :vision_statement,
  208. :active,
  209. key_messages: {},
  210. value_propositions: {},
  211. terminology: {},
  212. approved_phrases: [],
  213. banned_words: [],
  214. tone_attributes: {}
  215. )
  216. end
  217. def framework_json
  218. {
  219. id: @messaging_framework.id,
  220. tagline: @messaging_framework.tagline,
  221. mission_statement: @messaging_framework.mission_statement,
  222. vision_statement: @messaging_framework.vision_statement,
  223. key_messages: @messaging_framework.key_messages || {},
  224. value_propositions: @messaging_framework.value_propositions || {},
  225. terminology: @messaging_framework.terminology || {},
  226. approved_phrases: @messaging_framework.approved_phrases || [],
  227. banned_words: @messaging_framework.banned_words || [],
  228. tone_attributes: @messaging_framework.tone_attributes || {},
  229. active: @messaging_framework.active
  230. }
  231. end
  232. def analyze_tone_match(content)
  233. # Simple tone analysis - in production, this would use NLP
  234. tone = @messaging_framework.tone_attributes || {}
  235. {
  236. formality: tone['formality'] || 'neutral',
  237. matches_tone: true, # Simplified for now
  238. suggestions: []
  239. }
  240. end
  241. def find_approved_phrases_in_text(content)
  242. return [] unless @messaging_framework.approved_phrases.present?
  243. @messaging_framework.approved_phrases.select do |phrase|
  244. content.downcase.include?(phrase.downcase)
  245. end
  246. end
  247. def generate_csv
  248. require 'csv'
  249. CSV.generate(headers: true) do |csv|
  250. csv << ['Section', 'Key', 'Value']
  251. # Export key messages
  252. (@messaging_framework.key_messages || {}).each do |category, messages|
  253. messages.each { |msg| csv << ['Key Messages', category, msg] }
  254. end
  255. # Export value propositions
  256. (@messaging_framework.value_propositions || {}).each do |type, props|
  257. props.each { |prop| csv << ['Value Propositions', type, prop] }
  258. end
  259. # Export terminology
  260. (@messaging_framework.terminology || {}).each do |term, definition|
  261. csv << ['Terminology', term, definition]
  262. end
  263. # Export approved phrases
  264. (@messaging_framework.approved_phrases || []).each do |phrase|
  265. csv << ['Approved Phrases', '', phrase]
  266. end
  267. # Export banned words
  268. (@messaging_framework.banned_words || []).each do |word|
  269. csv << ['Banned Words', '', word]
  270. end
  271. # Export tone attributes
  272. (@messaging_framework.tone_attributes || {}).each do |attr, value|
  273. csv << ['Tone Attributes', attr, value]
  274. end
  275. end
  276. end
  277. def import_framework_data(file)
  278. # Handle JSON import
  279. if file.content_type == 'application/json'
  280. begin
  281. data = JSON.parse(file.read)
  282. @messaging_framework.update!(data.slice(*%w[key_messages value_propositions terminology approved_phrases banned_words tone_attributes tagline mission_statement vision_statement]))
  283. { success: true }
  284. rescue => e
  285. { success: false, errors: [e.message] }
  286. end
  287. else
  288. { success: false, errors: ['Unsupported file type. Please upload a JSON file.'] }
  289. end
  290. end
  291. def generate_ai_suggestions(content_type, current_content)
  292. # In production, this would call your AI service
  293. # For now, return sample suggestions
  294. case content_type
  295. when 'key_messages'
  296. [
  297. "Focus on customer benefits rather than features",
  298. "Include emotional appeal alongside rational arguments",
  299. "Ensure consistency with brand voice"
  300. ]
  301. when 'value_propositions'
  302. [
  303. "Lead with the primary benefit",
  304. "Quantify value where possible",
  305. "Differentiate from competitors"
  306. ]
  307. when 'tagline'
  308. [
  309. "Keep it under 7 words for memorability",
  310. "Include a unique brand element",
  311. "Make it actionable or aspirational"
  312. ]
  313. else
  314. ["No suggestions available for this content type"]
  315. end
  316. end
  317. end

app/controllers/passwords_controller.rb

0.0% lines covered

34 relevant lines. 0 lines covered and 34 lines missed.
    
  1. class PasswordsController < ApplicationController
  2. allow_unauthenticated_access
  3. before_action :set_user_by_token, only: %i[ edit update ]
  4. # Rate limit password reset requests to prevent abuse
  5. rate_limit to: 5, within: 1.hour, only: :create, with: -> {
  6. redirect_to new_password_path, alert: "Too many password reset requests. Please try again later."
  7. }
  8. def new
  9. end
  10. def create
  11. if user = User.find_by(email_address: params[:email_address])
  12. PasswordsMailer.reset(user).deliver_later
  13. end
  14. redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
  15. end
  16. def edit
  17. end
  18. def update
  19. if @user.update(user_params)
  20. redirect_to new_session_path, notice: "Password has been reset."
  21. else
  22. flash.now[:alert] = @user.errors.full_messages.to_sentence
  23. render :edit, status: :unprocessable_entity
  24. end
  25. end
  26. private
  27. def set_user_by_token
  28. @user = User.find_by_password_reset_token!(params[:token])
  29. rescue ActiveSupport::MessageVerifier::InvalidSignature
  30. redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
  31. end
  32. def user_params
  33. params.permit(:password, :password_confirmation)
  34. end
  35. end

app/controllers/profiles_controller.rb

0.0% lines covered

39 relevant lines. 0 lines covered and 39 lines missed.
    
  1. class ProfilesController < ApplicationController
  2. before_action :set_user
  3. before_action :authorize_user
  4. # Rate limit profile updates to prevent abuse
  5. rate_limit to: 30, within: 1.hour, only: :update, with: -> {
  6. redirect_to edit_profile_path, alert: "Too many update attempts. Please try again later."
  7. }
  8. def show
  9. end
  10. def edit
  11. end
  12. def update
  13. if @user.update(user_params)
  14. redirect_to profile_path, notice: "Profile updated successfully."
  15. else
  16. render :edit, status: :unprocessable_entity
  17. end
  18. end
  19. private
  20. def set_user
  21. @user = current_user
  22. end
  23. def authorize_user
  24. # Users can only view/edit their own profile
  25. redirect_to root_path, alert: "Not authorized" unless @user == current_user
  26. end
  27. def user_params
  28. params.require(:user).permit(
  29. :full_name,
  30. :bio,
  31. :phone_number,
  32. :company,
  33. :job_title,
  34. :timezone,
  35. :notification_email,
  36. :notification_marketing,
  37. :notification_product,
  38. :avatar
  39. )
  40. end
  41. end

app/controllers/rails_admin/application_controller.rb

0.0% lines covered

21 relevant lines. 0 lines covered and 21 lines missed.
    
  1. module RailsAdmin
  2. class ApplicationController < ::ApplicationController
  3. include AdminAuditable
  4. # Override to ensure we capture Rails Admin specific objects
  5. before_action :set_auditable_object
  6. private
  7. def set_auditable_object
  8. if params[:model_name].present?
  9. @model_name = params[:model_name]
  10. @abstract_model = RailsAdmin::AbstractModel.new(@model_name)
  11. if params[:id].present?
  12. @object = @abstract_model.get(params[:id])
  13. elsif action_name == "new"
  14. @object = @abstract_model.model.new
  15. end
  16. end
  17. end
  18. def _current_user
  19. current_user
  20. end
  21. end
  22. end

app/controllers/registrations_controller.rb

0.0% lines covered

22 relevant lines. 0 lines covered and 22 lines missed.
    
  1. class RegistrationsController < ApplicationController
  2. allow_unauthenticated_access
  3. # Rate limit registration attempts to prevent abuse
  4. rate_limit to: 5, within: 1.hour, only: :create, with: -> {
  5. redirect_to new_registration_path, alert: "Too many registration attempts. Please try again later."
  6. }
  7. def new
  8. @user = User.new
  9. end
  10. def create
  11. @user = User.new(user_params)
  12. if @user.save
  13. start_new_session_for(@user)
  14. redirect_to root_path, notice: "Welcome! You have successfully signed up."
  15. else
  16. render :new, status: :unprocessable_entity
  17. end
  18. end
  19. private
  20. def user_params
  21. params.require(:user).permit(:email_address, :password, :password_confirmation)
  22. end
  23. end

app/controllers/sessions_controller.rb

0.0% lines covered

55 relevant lines. 0 lines covered and 55 lines missed.
    
  1. require 'ostruct'
  2. class SessionsController < ApplicationController
  3. allow_unauthenticated_access only: %i[ new create ]
  4. rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_url, alert: "Try again later." }
  5. def new
  6. end
  7. def create
  8. if user = User.authenticate_by(params.permit(:email_address, :password))
  9. if user.locked?
  10. log_authentication_activity(user, success: false, reason: "account_locked")
  11. redirect_to new_session_path, alert: "Your account has been locked: #{user.lock_reason}"
  12. elsif user.suspended?
  13. log_authentication_activity(user, success: false, reason: "account_suspended")
  14. redirect_to new_session_path, alert: "Your account has been suspended: #{user.suspension_reason}"
  15. else
  16. start_new_session_for(user, remember_me: params[:remember_me] == "1")
  17. log_authentication_activity(user, success: true)
  18. redirect_to after_authentication_url
  19. end
  20. else
  21. # Log failed authentication attempt if we can identify the user
  22. if params[:email_address].present?
  23. failed_user = User.find_by(email_address: params[:email_address])
  24. log_authentication_activity(failed_user, success: false, reason: "invalid_credentials") if failed_user
  25. end
  26. redirect_to new_session_path, alert: "Try another email address or password."
  27. end
  28. end
  29. def destroy
  30. terminate_session
  31. redirect_to new_session_path
  32. end
  33. private
  34. def log_authentication_activity(user, success:, reason: nil)
  35. return unless user
  36. metadata = {
  37. success: success,
  38. reason: reason,
  39. ip_address: request.remote_ip,
  40. user_agent: request.user_agent
  41. }.compact
  42. activity = Activity.log_activity(
  43. user: user,
  44. action: "create",
  45. controller: "sessions",
  46. request: request,
  47. response: OpenStruct.new(status: success ? 302 : 401),
  48. metadata: metadata
  49. )
  50. # Check for suspicious activity
  51. if activity.persisted?
  52. SuspiciousActivityDetector.new(activity).check
  53. end
  54. rescue => e
  55. Rails.logger.error "Failed to log authentication activity: #{e.message}"
  56. end
  57. end

app/controllers/user_sessions_controller.rb

0.0% lines covered

21 relevant lines. 0 lines covered and 21 lines missed.
    
  1. class UserSessionsController < ApplicationController
  2. before_action :set_session, only: :destroy
  3. def index
  4. @sessions = current_user.sessions.active.order(last_active_at: :desc)
  5. @current_session = Current.session
  6. end
  7. def destroy
  8. if @session == Current.session
  9. # Can't destroy current session from this page
  10. redirect_to user_sessions_path, alert: "You cannot end your current session from here. Use Sign Out instead."
  11. else
  12. @session.destroy
  13. redirect_to user_sessions_path, notice: "Session ended successfully."
  14. end
  15. end
  16. private
  17. def set_session
  18. @session = current_user.sessions.find(params[:id])
  19. rescue ActiveRecord::RecordNotFound
  20. head :not_found
  21. end
  22. end

app/controllers/users_controller.rb

0.0% lines covered

14 relevant lines. 0 lines covered and 14 lines missed.
    
  1. class UsersController < ApplicationController
  2. before_action :set_user, only: [:show]
  3. def index
  4. @users = policy_scope(User)
  5. authorize User
  6. end
  7. def show
  8. authorize @user
  9. end
  10. private
  11. def set_user
  12. @user = User.find(params[:id])
  13. end
  14. end

app/helpers/activities_helper.rb

0.0% lines covered

2 relevant lines. 0 lines covered and 2 lines missed.
    
  1. module ActivitiesHelper
  2. end

app/helpers/api/v1/brand_compliance_helper.rb

0.0% lines covered

2 relevant lines. 0 lines covered and 2 lines missed.
    
  1. module Api::V1::BrandComplianceHelper
  2. end

app/helpers/application_helper.rb

0.0% lines covered

2 relevant lines. 0 lines covered and 2 lines missed.
    
  1. module ApplicationHelper
  2. end

app/helpers/brand_assets_helper.rb

0.0% lines covered

2 relevant lines. 0 lines covered and 2 lines missed.
    
  1. module BrandAssetsHelper
  2. end

app/helpers/brand_guidelines_helper.rb

0.0% lines covered

2 relevant lines. 0 lines covered and 2 lines missed.
    
  1. module BrandGuidelinesHelper
  2. end

app/helpers/brands_helper.rb

0.0% lines covered

2 relevant lines. 0 lines covered and 2 lines missed.
    
  1. module BrandsHelper
  2. end

app/helpers/home_helper.rb

0.0% lines covered

2 relevant lines. 0 lines covered and 2 lines missed.
    
  1. module HomeHelper
  2. end

app/helpers/journey_templates_helper.rb

0.0% lines covered

2 relevant lines. 0 lines covered and 2 lines missed.
    
  1. module JourneyTemplatesHelper
  2. end

app/helpers/messaging_frameworks_helper.rb

0.0% lines covered

2 relevant lines. 0 lines covered and 2 lines missed.
    
  1. module MessagingFrameworksHelper
  2. end

app/helpers/profiles_helper.rb

0.0% lines covered

2 relevant lines. 0 lines covered and 2 lines missed.
    
  1. module ProfilesHelper
  2. end

app/helpers/rails_admin/dashboard_helper.rb

0.0% lines covered

39 relevant lines. 0 lines covered and 39 lines missed.
    
  1. module RailsAdmin
  2. module DashboardHelper
  3. def user_growth_percentage
  4. current_count = User.where(created_at: Date.current.beginning_of_month..Date.current.end_of_month).count
  5. previous_count = User.where(created_at: 1.month.ago.beginning_of_month..1.month.ago.end_of_month).count
  6. return 0 if previous_count.zero?
  7. ((current_count - previous_count).to_f / previous_count * 100).round(2)
  8. end
  9. def activity_trend_percentage
  10. current_count = Activity.where(occurred_at: Date.current.beginning_of_day..Date.current.end_of_day).count
  11. previous_count = Activity.where(occurred_at: 1.day.ago.beginning_of_day..1.day.ago.end_of_day).count
  12. return 0 if previous_count.zero?
  13. ((current_count - previous_count).to_f / previous_count * 100).round(2)
  14. end
  15. def system_health_status
  16. error_rate = calculate_error_rate(24.hours)
  17. avg_response_time = calculate_average_response_time(24.hours)
  18. if error_rate > 5 || (avg_response_time && avg_response_time > 1.0)
  19. { status: "warning", color: "warning", icon: "exclamation-triangle" }
  20. elsif error_rate > 10
  21. { status: "critical", color: "danger", icon: "times-circle" }
  22. else
  23. { status: "healthy", color: "success", icon: "check-circle" }
  24. end
  25. end
  26. private
  27. def calculate_error_rate(time_window)
  28. total = Activity.where(occurred_at: time_window.ago..Time.current).count
  29. return 0 if total.zero?
  30. errors = Activity.where(response_status: 400..599, occurred_at: time_window.ago..Time.current).count
  31. (errors.to_f / total * 100).round(2)
  32. end
  33. def calculate_average_response_time(time_window)
  34. Activity.where.not(response_time: nil)
  35. .where(occurred_at: time_window.ago..Time.current)
  36. .average(:response_time)
  37. end
  38. end
  39. end

app/helpers/registrations_helper.rb

0.0% lines covered

2 relevant lines. 0 lines covered and 2 lines missed.
    
  1. module RegistrationsHelper
  2. end

app/helpers/user_sessions_helper.rb

0.0% lines covered

19 relevant lines. 0 lines covered and 19 lines missed.
    
  1. module UserSessionsHelper
  2. def parse_user_agent(user_agent_string)
  3. return "Unknown" if user_agent_string.blank?
  4. # Simple user agent parsing - in production, consider using a gem like 'browser'
  5. case user_agent_string
  6. when /Chrome\/(\d+)/
  7. "Chrome #{$1}"
  8. when /Safari\/(\d+)/
  9. "Safari"
  10. when /Firefox\/(\d+)/
  11. "Firefox #{$1}"
  12. when /Edge\/(\d+)/
  13. "Edge #{$1}"
  14. when /MSIE (\d+)/
  15. "Internet Explorer #{$1}"
  16. else
  17. user_agent_string.truncate(50)
  18. end
  19. end
  20. end

app/helpers/users_helper.rb

0.0% lines covered

2 relevant lines. 0 lines covered and 2 lines missed.
    
  1. module UsersHelper
  2. end

app/jobs/activity_cleanup_job.rb

0.0% lines covered

40 relevant lines. 0 lines covered and 40 lines missed.
    
  1. class ActivityCleanupJob < ApplicationJob
  2. queue_as :low
  3. def perform
  4. # Get retention period from configuration
  5. retention_days = Rails.application.config.activity_tracking.retention_days || 90
  6. cutoff_date = retention_days.days.ago
  7. # Log the cleanup operation
  8. ActivityLogger.log(:info, "Starting activity cleanup", {
  9. retention_days: retention_days,
  10. cutoff_date: cutoff_date
  11. })
  12. # Delete old activities in batches to avoid locking the table
  13. total_deleted = 0
  14. loop do
  15. deleted_count = Activity
  16. .where("occurred_at < ?", cutoff_date)
  17. .where(suspicious: false) # Keep suspicious activities longer
  18. .limit(1000)
  19. .delete_all
  20. total_deleted += deleted_count
  21. break if deleted_count < 1000
  22. # Small delay to prevent database overload
  23. sleep 0.1
  24. end
  25. # Clean up old user activities (if using the separate model)
  26. if defined?(UserActivity)
  27. UserActivity.where("performed_at < ?", cutoff_date).delete_all
  28. end
  29. # Log completion
  30. ActivityLogger.log(:info, "Activity cleanup completed", {
  31. total_deleted: total_deleted,
  32. cutoff_date: cutoff_date
  33. })
  34. # Run database optimization
  35. optimize_database_tables
  36. end
  37. private
  38. def optimize_database_tables
  39. # Optimize the activities table after bulk deletion
  40. if ActiveRecord::Base.connection.adapter_name == 'PostgreSQL'
  41. ActiveRecord::Base.connection.execute('VACUUM ANALYZE activities')
  42. elsif ActiveRecord::Base.connection.adapter_name.include?('SQLite')
  43. ActiveRecord::Base.connection.execute('VACUUM')
  44. end
  45. rescue => e
  46. Rails.logger.error "Failed to optimize database: #{e.message}"
  47. end
  48. end

app/jobs/application_job.rb

0.0% lines covered

2 relevant lines. 0 lines covered and 2 lines missed.
    
  1. class ApplicationJob < ActiveJob::Base
  2. # Automatically retry jobs that encountered a deadlock
  3. # retry_on ActiveRecord::Deadlocked
  4. # Most jobs are safe to ignore if the underlying records are no longer available
  5. # discard_on ActiveJob::DeserializationError
  6. end

app/jobs/brand_analysis_job.rb

0.0% lines covered

31 relevant lines. 0 lines covered and 31 lines missed.
    
  1. class BrandAnalysisJob < ApplicationJob
  2. queue_as :low_priority
  3. retry_on StandardError, wait: :exponentially_longer, attempts: 3
  4. def perform(analysis_id)
  5. analysis = BrandAnalysis.find(analysis_id)
  6. brand = analysis.brand
  7. # Initialize service with options from analysis metadata
  8. options = {
  9. llm_provider: analysis.analysis_data['llm_provider'],
  10. temperature: analysis.analysis_data['temperature'] || 0.7
  11. }
  12. service = Branding::AnalysisService.new(brand, nil, options)
  13. # Perform the actual analysis
  14. if service.perform_analysis(analysis)
  15. Rails.logger.info "Successfully analyzed brand #{brand.id} - Analysis #{analysis.id}"
  16. # Notify user or trigger follow-up actions
  17. BrandAnalysisNotificationJob.perform_later(brand, analysis.id)
  18. # Trigger content generation suggestions if enabled
  19. if brand.auto_generate_suggestions?
  20. ContentSuggestionJob.perform_later(brand, analysis.id)
  21. end
  22. else
  23. Rails.logger.error "Failed to analyze brand #{brand.id} - Analysis #{analysis.id}"
  24. # Notify user of failure
  25. BrandAnalysisNotificationJob.perform_later(brand, analysis.id, failed: true)
  26. end
  27. rescue ActiveRecord::RecordNotFound => e
  28. Rails.logger.error "Analysis not found: #{analysis_id} - #{e.message}"
  29. rescue StandardError => e
  30. Rails.logger.error "Brand analysis error: #{e.message}\n#{e.backtrace.join("\n")}"
  31. # Mark analysis as failed if we can
  32. if defined?(analysis) && analysis
  33. analysis.mark_as_failed!("Job error: #{e.message}")
  34. end
  35. raise # Re-raise for retry logic
  36. end
  37. end

app/jobs/brand_analysis_notification_job.rb

0.0% lines covered

6 relevant lines. 0 lines covered and 6 lines missed.
    
  1. class BrandAnalysisNotificationJob < ApplicationJob
  2. queue_as :default
  3. def perform(brand)
  4. # This would send notification to user about completed analysis
  5. # For now, we'll just log it
  6. Rails.logger.info "Brand analysis completed for #{brand.name} (ID: #{brand.id})"
  7. # In production, you might:
  8. # - Send an email notification
  9. # - Create an in-app notification
  10. # - Broadcast via ActionCable
  11. # - Update a dashboard metric
  12. end
  13. end

app/jobs/brand_asset_processing_job.rb

0.0% lines covered

15 relevant lines. 0 lines covered and 15 lines missed.
    
  1. class BrandAssetProcessingJob < ApplicationJob
  2. queue_as :default
  3. def perform(brand_asset)
  4. return unless brand_asset.file.attached?
  5. processor = Branding::AssetProcessor.new(brand_asset)
  6. if processor.process
  7. Rails.logger.info "Successfully processed brand asset #{brand_asset.id}"
  8. # Trigger brand analysis if this is the first processed asset
  9. if brand_asset.brand.brand_assets.processed.count == 1
  10. BrandAnalysisJob.perform_later(brand_asset.brand)
  11. end
  12. else
  13. Rails.logger.error "Failed to process brand asset #{brand_asset.id}: #{processor.errors.join(', ')}"
  14. end
  15. end
  16. end

app/jobs/brand_compliance_job.rb

0.0% lines covered

113 relevant lines. 0 lines covered and 113 lines missed.
    
  1. class BrandComplianceJob < ApplicationJob
  2. queue_as :default
  3. # Retry configuration for transient failures
  4. retry_on StandardError, wait: :exponentially_longer, attempts: 3
  5. # Discard jobs with permanent failures after retries
  6. discard_on ActiveJob::DeserializationError
  7. def perform(brand_id, content, content_type, options = {})
  8. brand = Brand.find(brand_id)
  9. # Initialize event broadcaster if real-time updates are enabled
  10. broadcaster = if options[:broadcast_events]
  11. Branding::Compliance::EventBroadcaster.new(
  12. brand_id,
  13. options[:session_id],
  14. options[:user_id]
  15. )
  16. end
  17. # Broadcast start event
  18. broadcaster&.broadcast_validation_start({
  19. type: content_type,
  20. length: content.length,
  21. validators: determine_validators(content_type, options)
  22. })
  23. # Perform compliance check
  24. service = Branding::ComplianceServiceV2.new(brand, content, content_type, options)
  25. results = service.check_compliance
  26. # Store results if requested
  27. if options[:store_results]
  28. store_compliance_results(brand, results, options)
  29. end
  30. # Broadcast completion
  31. broadcaster&.broadcast_validation_complete(results)
  32. # Send notifications if needed
  33. send_notifications(brand, results, options) if options[:notify]
  34. # Return results for job tracking
  35. results
  36. rescue StandardError => e
  37. handle_job_error(e, broadcaster, options)
  38. raise # Re-raise for retry mechanism
  39. end
  40. private
  41. def determine_validators(content_type, options)
  42. validators = ["Rule Engine"]
  43. validators << "NLP Analyzer" unless content_type.include?("visual")
  44. validators << "Visual Validator" if content_type.include?("visual") || content_type.include?("image")
  45. validators
  46. end
  47. def store_compliance_results(brand, results, options)
  48. ComplianceResult.create!(
  49. brand: brand,
  50. content_type: options[:content_type],
  51. content_hash: Digest::SHA256.hexdigest(options[:content_identifier] || ""),
  52. compliant: results[:compliant],
  53. score: results[:score],
  54. violations_count: results[:violations]&.count || 0,
  55. violations_data: results[:violations],
  56. suggestions_data: results[:suggestions],
  57. analysis_data: results[:analysis],
  58. metadata: {
  59. processing_time: results[:metadata][:processing_time],
  60. validators_used: results[:metadata][:validators_used],
  61. options: options.except(:content)
  62. }
  63. )
  64. rescue StandardError => e
  65. Rails.logger.error "Failed to store compliance results: #{e.message}"
  66. end
  67. def send_notifications(brand, results, options)
  68. return if results[:compliant] && !options[:notify_on_success]
  69. # Determine notification recipients
  70. recipients = determine_recipients(brand, options)
  71. # Send appropriate notifications
  72. if results[:compliant]
  73. ComplianceMailer.compliance_passed(brand, results, recipients).deliver_later
  74. else
  75. ComplianceMailer.compliance_failed(brand, results, recipients).deliver_later
  76. end
  77. # Send in-app notifications if enabled
  78. if options[:in_app_notifications]
  79. create_in_app_notifications(brand, results, recipients)
  80. end
  81. end
  82. def determine_recipients(brand, options)
  83. recipients = []
  84. # Brand owner
  85. recipients << brand.user if options[:notify_owner]
  86. # Specified users
  87. if options[:notify_users]
  88. recipients.concat(User.where(id: options[:notify_users]))
  89. end
  90. # Team members with appropriate permissions
  91. if options[:notify_team]
  92. recipients.concat(brand.team_members.with_permission(:view_compliance))
  93. end
  94. recipients.uniq
  95. end
  96. def create_in_app_notifications(brand, results, recipients)
  97. recipients.each do |recipient|
  98. Notification.create!(
  99. user: recipient,
  100. notifiable: brand,
  101. action: results[:compliant] ? "compliance_passed" : "compliance_failed",
  102. data: {
  103. score: results[:score],
  104. violations_count: results[:violations]&.count || 0,
  105. summary: results[:summary]
  106. }
  107. )
  108. end
  109. end
  110. def handle_job_error(error, broadcaster, options)
  111. Rails.logger.error "Compliance job error: #{error.message}"
  112. Rails.logger.error error.backtrace.join("\n")
  113. # Broadcast error event
  114. broadcaster&.broadcast_error({
  115. type: error.class.name,
  116. message: error.message,
  117. recoverable: !error.is_a?(ActiveRecord::RecordNotFound)
  118. })
  119. # Store error information if requested
  120. if options[:store_errors]
  121. ComplianceError.create!(
  122. brand_id: options[:brand_id],
  123. error_type: error.class.name,
  124. error_message: error.message,
  125. error_backtrace: error.backtrace,
  126. job_params: options
  127. )
  128. end
  129. end
  130. end

app/jobs/branding/compliance/cache_warmer_job.rb

0.0% lines covered

11 relevant lines. 0 lines covered and 11 lines missed.
    
  1. module Branding
  2. module Compliance
  3. class CacheWarmerJob < ApplicationJob
  4. queue_as :low
  5. def perform(brand_id)
  6. brand = Brand.find(brand_id)
  7. CacheService.preload_brand_cache(brand)
  8. end
  9. end
  10. end
  11. end

app/jobs/journey_suggestions_cache_warmup_job.rb

0.0% lines covered

48 relevant lines. 0 lines covered and 48 lines missed.
    
  1. class JourneySuggestionsCacheWarmupJob < ApplicationJob
  2. queue_as :low_priority
  3. def perform
  4. return unless cache_warming_enabled?
  5. Rails.logger.info "Starting journey suggestions cache warmup"
  6. # Warm cache for active journeys with recent activity
  7. active_journeys = Journey.published
  8. .joins(:journey_executions)
  9. .where('journey_executions.updated_at > ?', 7.days.ago)
  10. .distinct
  11. .limit(batch_size)
  12. active_journeys.find_each do |journey|
  13. warm_journey_cache(journey)
  14. end
  15. Rails.logger.info "Completed journey suggestions cache warmup for #{active_journeys.count} journeys"
  16. end
  17. private
  18. def cache_warming_enabled?
  19. Rails.application.config.journey_suggestions[:cache_warming][:enabled]
  20. end
  21. def batch_size
  22. Rails.application.config.journey_suggestions[:cache_warming][:batch_size]
  23. end
  24. def warm_journey_cache(journey)
  25. return unless journey.user
  26. # Warm suggestions cache for common scenarios
  27. common_providers = [:openai, :anthropic]
  28. common_filters = [
  29. {},
  30. { stage: 'awareness' },
  31. { stage: 'conversion' },
  32. { content_type: 'email' }
  33. ]
  34. common_providers.each do |provider|
  35. common_filters.each do |filters|
  36. begin
  37. engine = JourneySuggestionEngine.new(
  38. journey: journey,
  39. user: journey.user,
  40. provider: provider
  41. )
  42. # Generate suggestions to populate cache
  43. engine.generate_suggestions(filters)
  44. sleep(0.1) # Rate limiting
  45. rescue => e
  46. Rails.logger.warn "Cache warmup failed for journey #{journey.id} with provider #{provider}: #{e.message}"
  47. end
  48. end
  49. end
  50. end
  51. end

app/jobs/suspicious_activity_alert_job.rb

0.0% lines covered

44 relevant lines. 0 lines covered and 44 lines missed.
    
  1. class SuspiciousActivityAlertJob < ApplicationJob
  2. queue_as :critical
  3. def perform(activity_id, reasons)
  4. activity = Activity.find(activity_id)
  5. # Send email to admins
  6. AdminMailer.suspicious_activity_alert(activity, reasons).deliver_later
  7. # Log to security monitoring system
  8. log_to_security_monitoring(activity, reasons)
  9. # Check if user should be temporarily locked
  10. check_user_lockout(activity.user, reasons)
  11. rescue ActiveRecord::RecordNotFound
  12. Rails.logger.error "Activity #{activity_id} not found for suspicious activity alert"
  13. end
  14. private
  15. def log_to_security_monitoring(activity, reasons)
  16. log_message = <<~LOG
  17. [SECURITY] Suspicious Activity Detected:
  18. User: #{activity.user.email_address} (ID: #{activity.user.id})
  19. IP: #{activity.ip_address}
  20. Action: #{activity.full_action}
  21. Path: #{activity.request_path}
  22. Reasons: #{reasons.join(", ")}
  23. Time: #{activity.occurred_at}
  24. User Agent: #{activity.user_agent}
  25. LOG
  26. Rails.logger.warn log_message
  27. end
  28. def check_user_lockout(user, reasons)
  29. # Lock user if there are critical security concerns
  30. critical_reasons = ["failed_login_attempts", "ip_hopping", "excessive_errors"]
  31. if (reasons & critical_reasons).any?
  32. recent_suspicious_count = user.activities
  33. .suspicious
  34. .where("occurred_at > ?", 1.hour.ago)
  35. .count
  36. if recent_suspicious_count >= 3
  37. lock_user_temporarily(user)
  38. end
  39. end
  40. end
  41. def lock_user_temporarily(user)
  42. user.update!(
  43. locked_at: Time.current,
  44. lock_reason: "Suspicious activity detected"
  45. )
  46. # Send notification to user
  47. UserMailer.account_temporarily_locked(user).deliver_later
  48. end
  49. end

app/mailers/admin_mailer.rb

0.0% lines covered

84 relevant lines. 0 lines covered and 84 lines missed.
    
  1. class AdminMailer < ApplicationMailer
  2. helper_method :rails_admin_url_for
  3. def suspicious_activity_alert(activity, reasons)
  4. @activity = activity
  5. @reasons = reasons
  6. @user = activity.user
  7. # Get all admin users
  8. admin_emails = User.where(role: :admin).pluck(:email_address)
  9. mail(
  10. to: admin_emails,
  11. subject: "[SECURITY ALERT] Suspicious activity detected for #{@user.email_address}"
  12. )
  13. end
  14. def daily_activity_report(admin, report)
  15. @admin = admin
  16. @report = report
  17. @date = Date.current - 1.day
  18. mail(
  19. to: admin.email_address,
  20. subject: "Daily Activity Report - #{@date.strftime('%B %d, %Y')}"
  21. )
  22. end
  23. def security_scan_alert(suspicious_users)
  24. @suspicious_users = suspicious_users
  25. @scan_time = Time.current
  26. # Get all admin users
  27. admin_emails = User.where(role: :admin).pluck(:email_address)
  28. mail(
  29. to: admin_emails,
  30. subject: "[SECURITY] Automated scan detected #{suspicious_users.count} suspicious users"
  31. )
  32. end
  33. def system_maintenance_report(admin_user, maintenance_results)
  34. @admin_user = admin_user
  35. @maintenance_results = maintenance_results
  36. @maintenance_time = Time.current
  37. mail(to: admin_user.email_address, subject: "System Maintenance Report - #{@maintenance_time.strftime('%m/%d/%Y')}")
  38. end
  39. def user_account_alert(admin_user, user, alert_type, details = {})
  40. @admin_user = admin_user
  41. @user = user
  42. @alert_type = alert_type
  43. @details = details
  44. @alert_time = Time.current
  45. subject = case alert_type
  46. when 'locked'
  47. "User Account Locked - #{user.email_address}"
  48. when 'suspended'
  49. "User Account Suspended - #{user.email_address}"
  50. when 'multiple_failed_logins'
  51. "Multiple Failed Login Attempts - #{user.email_address}"
  52. else
  53. "User Account Alert - #{user.email_address}"
  54. end
  55. mail(to: admin_user.email_address, subject: subject)
  56. end
  57. def system_health_alert(admin_user, health_status, metrics)
  58. @admin_user = admin_user
  59. @health_status = health_status
  60. @metrics = metrics
  61. @alert_time = Time.current
  62. subject = case health_status
  63. when 'critical'
  64. "🚨 CRITICAL System Health Alert"
  65. when 'warning'
  66. "⚠️ System Health Warning"
  67. else
  68. "System Health Status Update"
  69. end
  70. mail(to: admin_user.email_address, subject: subject)
  71. end
  72. def weekly_summary_report(admin_user, summary_data)
  73. @admin_user = admin_user
  74. @summary_data = summary_data
  75. @week_start = 1.week.ago.beginning_of_week
  76. @week_end = Date.current.end_of_week
  77. mail(to: admin_user.email_address, subject: "Weekly Summary Report - #{@week_start.strftime('%m/%d')} to #{@week_end.strftime('%m/%d/%Y')}")
  78. end
  79. private
  80. def rails_admin_url_for(object, action = :show)
  81. host = Rails.application.config.action_mailer.default_url_options[:host] || 'localhost:3000'
  82. protocol = Rails.application.config.action_mailer.default_url_options[:protocol] || 'http'
  83. model_name = object.class.name.underscore
  84. "#{protocol}://#{host}/admin/#{model_name}/#{object.id}"
  85. end
  86. end

app/mailers/application_mailer.rb

0.0% lines covered

4 relevant lines. 0 lines covered and 4 lines missed.
    
  1. class ApplicationMailer < ActionMailer::Base
  2. default from: "from@example.com"
  3. layout "mailer"
  4. end

app/mailers/passwords_mailer.rb

0.0% lines covered

6 relevant lines. 0 lines covered and 6 lines missed.
    
  1. class PasswordsMailer < ApplicationMailer
  2. def reset(user)
  3. @user = user
  4. mail subject: "Reset your password", to: user.email_address
  5. end
  6. end

app/mailers/user_mailer.rb

0.0% lines covered

10 relevant lines. 0 lines covered and 10 lines missed.
    
  1. class UserMailer < ApplicationMailer
  2. def account_temporarily_locked(user)
  3. @user = user
  4. @unlock_time = 1.hour.from_now
  5. mail(
  6. to: @user.email_address,
  7. subject: "Your account has been temporarily locked"
  8. )
  9. end
  10. end

app/models/ab_test.rb

0.0% lines covered

232 relevant lines. 0 lines covered and 232 lines missed.
    
  1. class AbTest < ApplicationRecord
  2. belongs_to :campaign
  3. belongs_to :user
  4. has_many :ab_test_variants, dependent: :destroy
  5. has_many :journeys, through: :ab_test_variants
  6. belongs_to :winner_variant, class_name: 'AbTestVariant', optional: true
  7. STATUSES = %w[draft running paused completed cancelled].freeze
  8. TEST_TYPES = %w[
  9. conversion engagement retention click_through
  10. bounce_rate time_on_page form_completion
  11. email_open email_click purchase revenue
  12. ].freeze
  13. validates :name, presence: true, uniqueness: { scope: :campaign_id }
  14. validates :status, inclusion: { in: STATUSES }
  15. validates :test_type, inclusion: { in: TEST_TYPES }
  16. validates :confidence_level, presence: true, numericality: {
  17. greater_than: 50, less_than_or_equal_to: 99.9
  18. }
  19. validates :significance_threshold, presence: true, numericality: {
  20. greater_than: 0, less_than_or_equal_to: 20
  21. }
  22. validate :end_date_after_start_date
  23. validate :variants_traffic_percentage_sum
  24. scope :active, -> { where(status: ['running', 'paused']) }
  25. scope :completed, -> { where(status: 'completed') }
  26. scope :by_type, ->(type) { where(test_type: type) }
  27. scope :recent, -> { order(created_at: :desc) }
  28. scope :running, -> { where(status: 'running') }
  29. def start!
  30. return false unless can_start?
  31. update!(status: 'running', start_date: Time.current)
  32. # Start tracking for all variants
  33. ab_test_variants.each(&:reset_metrics!)
  34. true
  35. end
  36. def pause!
  37. update!(status: 'paused')
  38. end
  39. def resume!
  40. return false unless paused?
  41. update!(status: 'running')
  42. end
  43. def complete!
  44. return false unless running?
  45. determine_winner!
  46. update!(status: 'completed', end_date: Time.current)
  47. end
  48. def cancel!
  49. update!(status: 'cancelled', end_date: Time.current)
  50. end
  51. def running?
  52. status == 'running'
  53. end
  54. def paused?
  55. status == 'paused'
  56. end
  57. def completed?
  58. status == 'completed'
  59. end
  60. def can_start?
  61. draft? && ab_test_variants.count >= 2 && valid_traffic_allocation?
  62. end
  63. def draft?
  64. status == 'draft'
  65. end
  66. def duration_days
  67. return 0 unless start_date
  68. end_time = end_date || Time.current
  69. ((end_time - start_date) / 1.day).round(1)
  70. end
  71. def progress_percentage
  72. return 0 unless start_date && end_date
  73. # Calculate how much time has elapsed vs planned duration
  74. elapsed_time = Time.current - start_date
  75. planned_time = end_date - start_date
  76. return 100 if elapsed_time >= planned_time
  77. elapsed_days = elapsed_time / 1.day
  78. planned_days = planned_time / 1.day
  79. [(elapsed_days / planned_days * 100).round, 100].min
  80. end
  81. def planned_duration_days
  82. return 0 unless start_date && end_date
  83. ((end_date - start_date) / 1.day).round(1)
  84. end
  85. def statistical_significance_reached?
  86. return false unless running? || completed?
  87. control_variant = ab_test_variants.find_by(is_control: true)
  88. return false unless control_variant
  89. treatment_variants = ab_test_variants.where(is_control: false)
  90. treatment_variants.any? do |variant|
  91. calculate_statistical_significance(control_variant, variant) >= significance_threshold
  92. end
  93. end
  94. def determine_winner!
  95. return if ab_test_variants.count < 2
  96. # Find the variant with the highest conversion rate that is statistically significant
  97. control_variant = ab_test_variants.find_by(is_control: true)
  98. return unless control_variant
  99. significant_variants = ab_test_variants.select do |variant|
  100. next true if variant.is_control? # Control is always included
  101. calculate_statistical_significance(control_variant, variant) >= significance_threshold
  102. end
  103. return if significant_variants.empty?
  104. winner = significant_variants.max_by(&:conversion_rate)
  105. update!(winner_variant: winner) if winner
  106. end
  107. def winner_declared?
  108. winner_variant.present?
  109. end
  110. def results_summary
  111. return {} unless ab_test_variants.any?
  112. control = ab_test_variants.find_by(is_control: true)
  113. treatments = ab_test_variants.where(is_control: false)
  114. {
  115. test_name: name,
  116. status: status,
  117. duration_days: duration_days,
  118. statistical_significance: statistical_significance_reached?,
  119. winner: winner_variant&.name,
  120. control_performance: control&.performance_summary,
  121. treatment_performances: treatments.map(&:performance_summary),
  122. confidence_level: confidence_level,
  123. total_visitors: ab_test_variants.sum(:total_visitors),
  124. overall_conversion_rate: calculate_overall_conversion_rate
  125. }
  126. end
  127. def variant_comparison
  128. return [] unless ab_test_variants.count >= 2
  129. control = ab_test_variants.find_by(is_control: true)
  130. return [] unless control
  131. treatments = ab_test_variants.where(is_control: false)
  132. treatments.map do |treatment|
  133. significance = calculate_statistical_significance(control, treatment)
  134. lift = calculate_lift(control, treatment)
  135. {
  136. variant_name: treatment.name,
  137. control_conversion_rate: control.conversion_rate,
  138. treatment_conversion_rate: treatment.conversion_rate,
  139. lift_percentage: lift,
  140. statistical_significance: significance,
  141. is_significant: significance >= significance_threshold,
  142. confidence_interval: calculate_confidence_interval(treatment),
  143. sample_size: treatment.total_visitors
  144. }
  145. end
  146. end
  147. def recommend_action
  148. return 'Test not yet started' unless running? || completed?
  149. return 'Insufficient data' if ab_test_variants.sum(:total_visitors) < 100
  150. if statistical_significance_reached?
  151. if winner_declared?
  152. "Implement #{winner_variant.name} variant (statistically significant winner)"
  153. else
  154. 'Continue test - significance reached but no clear winner'
  155. end
  156. else
  157. if duration_days > 14
  158. 'Consider extending test duration or increasing traffic'
  159. else
  160. 'Continue test - more data needed for statistical significance'
  161. end
  162. end
  163. end
  164. def self.create_basic_ab_test(campaign, name, control_journey, treatment_journey, test_type = 'conversion')
  165. test = create!(
  166. campaign: campaign,
  167. user: campaign.user,
  168. name: name,
  169. test_type: test_type,
  170. hypothesis: "Treatment journey will outperform control journey for #{test_type}"
  171. )
  172. # Create control variant
  173. test.ab_test_variants.create!(
  174. journey: control_journey,
  175. name: 'Control',
  176. is_control: true,
  177. traffic_percentage: 50.0
  178. )
  179. # Create treatment variant
  180. test.ab_test_variants.create!(
  181. journey: treatment_journey,
  182. name: 'Treatment',
  183. is_control: false,
  184. traffic_percentage: 50.0
  185. )
  186. test
  187. end
  188. private
  189. def end_date_after_start_date
  190. return unless start_date && end_date
  191. errors.add(:end_date, 'must be after start date') if end_date <= start_date
  192. end
  193. def variants_traffic_percentage_sum
  194. return unless ab_test_variants.any?
  195. total_percentage = ab_test_variants.sum(:traffic_percentage)
  196. unless (99.0..101.0).cover?(total_percentage)
  197. errors.add(:base, 'Variant traffic percentages must sum to 100%')
  198. end
  199. end
  200. def valid_traffic_allocation?
  201. return false unless ab_test_variants.any?
  202. total_percentage = ab_test_variants.sum(:traffic_percentage)
  203. (99.0..101.0).cover?(total_percentage)
  204. end
  205. def calculate_statistical_significance(control, treatment)
  206. return 0 if control.total_visitors == 0 || treatment.total_visitors == 0
  207. # Simplified z-test calculation for conversion rates
  208. p1 = control.conversion_rate / 100.0
  209. p2 = treatment.conversion_rate / 100.0
  210. n1 = control.total_visitors
  211. n2 = treatment.total_visitors
  212. # Pooled proportion
  213. p_pool = (control.conversions + treatment.conversions).to_f / (n1 + n2)
  214. # Standard error
  215. se = Math.sqrt(p_pool * (1 - p_pool) * (1.0/n1 + 1.0/n2))
  216. return 0 if se == 0
  217. # Z-score
  218. z = (p2 - p1).abs / se
  219. # Convert to significance percentage (simplified)
  220. significance = [(1 - Math.exp(-z * z / 2)) * 100, 99.9].min
  221. significance.round(1)
  222. end
  223. def calculate_lift(control, treatment)
  224. return 0 if control.conversion_rate == 0
  225. ((treatment.conversion_rate - control.conversion_rate) / control.conversion_rate * 100).round(1)
  226. end
  227. def calculate_confidence_interval(variant)
  228. return [0, 0] if variant.total_visitors == 0
  229. p = variant.conversion_rate / 100.0
  230. n = variant.total_visitors
  231. # 95% confidence interval for proportion
  232. margin_of_error = 1.96 * Math.sqrt(p * (1 - p) / n)
  233. lower = [(p - margin_of_error) * 100, 0].max
  234. upper = [(p + margin_of_error) * 100, 100].min
  235. [lower.round(1), upper.round(1)]
  236. end
  237. def calculate_overall_conversion_rate
  238. total_visitors = ab_test_variants.sum(:total_visitors)
  239. return 0 if total_visitors == 0
  240. total_conversions = ab_test_variants.sum(:conversions)
  241. (total_conversions.to_f / total_visitors * 100).round(2)
  242. end
  243. end

app/models/ab_test_variant.rb

0.0% lines covered

176 relevant lines. 0 lines covered and 176 lines missed.
    
  1. class AbTestVariant < ApplicationRecord
  2. belongs_to :ab_test
  3. belongs_to :journey
  4. has_one :campaign, through: :ab_test
  5. has_one :user, through: :ab_test
  6. VARIANT_TYPES = %w[control treatment variation].freeze
  7. validates :name, presence: true, uniqueness: { scope: :ab_test_id }
  8. validates :variant_type, inclusion: { in: VARIANT_TYPES }
  9. validates :traffic_percentage, presence: true, numericality: {
  10. greater_than: 0, less_than_or_equal_to: 100
  11. }
  12. validates :total_visitors, presence: true, numericality: { greater_than_or_equal_to: 0 }
  13. validates :conversions, presence: true, numericality: { greater_than_or_equal_to: 0 }
  14. validates :conversion_rate, presence: true, numericality: {
  15. greater_than_or_equal_to: 0, less_than_or_equal_to: 100
  16. }
  17. validate :conversions_not_exceed_visitors
  18. validate :only_one_control_per_test
  19. scope :control, -> { where(is_control: true) }
  20. scope :treatments, -> { where(is_control: false) }
  21. scope :by_conversion_rate, -> { order(conversion_rate: :desc) }
  22. scope :significant, -> { where('confidence_interval > ?', 95.0) }
  23. before_save :calculate_conversion_rate
  24. def control?
  25. is_control
  26. end
  27. def treatment?
  28. !is_control
  29. end
  30. def reset_metrics!
  31. update!(
  32. total_visitors: 0,
  33. conversions: 0,
  34. conversion_rate: 0.0,
  35. confidence_interval: 0.0
  36. )
  37. end
  38. def record_visitor!
  39. increment!(:total_visitors)
  40. calculate_and_update_conversion_rate
  41. end
  42. def record_conversion!
  43. increment!(:conversions)
  44. calculate_and_update_conversion_rate
  45. end
  46. def performance_summary
  47. {
  48. name: name,
  49. variant_type: variant_type,
  50. is_control: is_control,
  51. traffic_percentage: traffic_percentage,
  52. total_visitors: total_visitors,
  53. conversions: conversions,
  54. conversion_rate: conversion_rate,
  55. confidence_interval: confidence_interval,
  56. journey_name: journey.name
  57. }
  58. end
  59. def sample_size_adequate?
  60. # Rule of thumb: at least 100 visitors and 10 conversions for meaningful results
  61. total_visitors >= 100 && conversions >= 10
  62. end
  63. def statistical_power
  64. return 0 if total_visitors == 0
  65. # Simplified power calculation based on sample size
  66. # In practice, this would use more sophisticated statistical methods
  67. case total_visitors
  68. when 0..99 then 'Low'
  69. when 100..499 then 'Medium'
  70. when 500..999 then 'High'
  71. else 'Very High'
  72. end
  73. end
  74. def lift_vs_control
  75. return 0 unless ab_test && ab_test.ab_test_variants.any?
  76. control_variant = ab_test.ab_test_variants.find_by(is_control: true)
  77. return 0 unless control_variant && control_variant != self
  78. return 0 if control_variant.conversion_rate == 0
  79. ((conversion_rate - control_variant.conversion_rate) / control_variant.conversion_rate * 100).round(1)
  80. end
  81. def significance_vs_control
  82. return 0 unless ab_test && ab_test.ab_test_variants.any?
  83. control_variant = ab_test.ab_test_variants.find_by(is_control: true)
  84. return 0 unless control_variant && control_variant != self
  85. calculate_significance_against(control_variant)
  86. end
  87. def confidence_interval_range
  88. return [0, 0] if total_visitors == 0
  89. p = conversion_rate / 100.0
  90. n = total_visitors
  91. # Calculate 95% confidence interval
  92. margin_of_error = 1.96 * Math.sqrt(p * (1 - p) / n)
  93. lower = [(p - margin_of_error) * 100, 0].max
  94. upper = [(p + margin_of_error) * 100, 100].min
  95. [lower.round(1), upper.round(1)]
  96. end
  97. def expected_visitors_per_day
  98. return 0 unless ab_test.start_date && ab_test.running?
  99. days_running = [(Time.current - ab_test.start_date) / 1.day, 1].max
  100. (total_visitors / days_running).round
  101. end
  102. def days_to_significance(target_significance = 95.0)
  103. return 'N/A' unless ab_test.running? && expected_visitors_per_day > 0
  104. # Simplified calculation - in practice would use power analysis
  105. control_variant = ab_test.ab_test_variants.find_by(is_control: true)
  106. return 'N/A' unless control_variant
  107. current_significance = significance_vs_control
  108. return 'Already significant' if current_significance >= target_significance
  109. # Estimate additional visitors needed (simplified)
  110. additional_visitors_needed = [500 - total_visitors, 0].max
  111. days_needed = (additional_visitors_needed / expected_visitors_per_day).ceil
  112. "~#{days_needed} days"
  113. end
  114. def journey_performance_context
  115. {
  116. journey_name: journey.name,
  117. journey_status: journey.status,
  118. total_steps: journey.total_steps,
  119. completion_rate: journey_completion_rate,
  120. average_journey_time: average_journey_completion_time
  121. }
  122. end
  123. def detailed_metrics
  124. base_metrics = performance_summary
  125. base_metrics.merge({
  126. lift_vs_control: lift_vs_control,
  127. significance_vs_control: significance_vs_control,
  128. confidence_interval_range: confidence_interval_range,
  129. sample_size_adequate: sample_size_adequate?,
  130. statistical_power: statistical_power,
  131. expected_visitors_per_day: expected_visitors_per_day,
  132. days_to_significance: days_to_significance,
  133. journey_context: journey_performance_context
  134. })
  135. end
  136. def calculate_required_sample_size(desired_lift = 20, power = 0.8, alpha = 0.05)
  137. # Simplified sample size calculation for A/B test
  138. # In practice, would use more sophisticated statistical methods
  139. baseline_rate = is_control ? (conversion_rate / 100.0) : 0.05 # Default 5% if not control
  140. effect_size = baseline_rate * (desired_lift / 100.0)
  141. # Simplified formula - actual calculation would be more complex
  142. estimated_sample_size = (2 * (1.96 + 0.84)**2 * baseline_rate * (1 - baseline_rate)) / (effect_size**2)
  143. estimated_sample_size.round
  144. end
  145. private
  146. def conversions_not_exceed_visitors
  147. return unless total_visitors && conversions
  148. errors.add(:conversions, 'cannot exceed total visitors') if conversions > total_visitors
  149. end
  150. def only_one_control_per_test
  151. return unless is_control? && ab_test
  152. existing_control = ab_test.ab_test_variants.where(is_control: true).where.not(id: id).exists?
  153. errors.add(:is_control, 'only one control variant allowed per test') if existing_control
  154. end
  155. def calculate_conversion_rate
  156. self.conversion_rate = if total_visitors > 0
  157. (conversions.to_f / total_visitors * 100).round(2)
  158. else
  159. 0.0
  160. end
  161. end
  162. def calculate_and_update_conversion_rate
  163. calculate_conversion_rate
  164. save! if changed?
  165. end
  166. def calculate_significance_against(other_variant)
  167. return 0 if total_visitors == 0 || other_variant.total_visitors == 0
  168. # Z-test for proportions
  169. p1 = conversion_rate / 100.0
  170. p2 = other_variant.conversion_rate / 100.0
  171. n1 = total_visitors
  172. n2 = other_variant.total_visitors
  173. # Pooled proportion
  174. p_pool = (conversions + other_variant.conversions).to_f / (n1 + n2)
  175. # Standard error
  176. se = Math.sqrt(p_pool * (1 - p_pool) * (1.0/n1 + 1.0/n2))
  177. return 0 if se == 0
  178. # Z-score
  179. z = (p1 - p2).abs / se
  180. # Convert to confidence level (simplified)
  181. confidence = [(1 - Math.exp(-z * z / 2)) * 100, 99.9].min
  182. confidence.round(1)
  183. end
  184. def journey_completion_rate
  185. # This would integrate with actual journey execution data
  186. # For now, return conversion rate as a proxy
  187. conversion_rate
  188. end
  189. def average_journey_completion_time
  190. # This would integrate with actual journey execution timing data
  191. # For now, return a placeholder
  192. journey.journey_steps.sum(:duration_days)
  193. end
  194. end

app/models/activity.rb

50.88% lines covered

57 relevant lines. 29 lines covered and 28 lines missed.
    
  1. 1 class Activity < ApplicationRecord
  2. 1 belongs_to :user
  3. # Validations
  4. 1 validates :action, presence: true
  5. 1 validates :controller, presence: true
  6. 1 validates :occurred_at, presence: true
  7. # Scopes
  8. 1 scope :recent, -> { order(occurred_at: :desc) }
  9. 1 scope :suspicious, -> { where(suspicious: true) }
  10. 1 scope :normal, -> { where(suspicious: false) }
  11. 1 scope :by_user, ->(user) { where(user: user) }
  12. 1 scope :by_action, ->(action) { where(action: action) }
  13. 1 scope :by_controller, ->(controller) { where(controller: controller) }
  14. 1 scope :today, -> { where(occurred_at: Time.current.beginning_of_day..Time.current.end_of_day) }
  15. 1 scope :this_week, -> { where(occurred_at: Time.current.beginning_of_week..Time.current.end_of_week) }
  16. 1 scope :this_month, -> { where(occurred_at: Time.current.beginning_of_month..Time.current.end_of_month) }
  17. 1 scope :failed_requests, -> { where("response_status >= ?", 400) }
  18. 1 scope :successful_requests, -> { where("response_status < ?", 400) }
  19. # Callbacks
  20. 1 before_validation :set_occurred_at, on: :create
  21. # Serialize metadata
  22. 1 serialize :metadata, coder: JSON
  23. # Class methods
  24. 1 def self.log_activity(user:, action:, controller:, request:, response: nil, metadata: {})
  25. create!(
  26. user: user,
  27. action: action,
  28. controller: controller,
  29. request_path: request.path,
  30. request_method: request.method,
  31. ip_address: request.remote_ip,
  32. user_agent: request.user_agent,
  33. session_id: request.session.id,
  34. referrer: request.referrer,
  35. response_status: response&.status,
  36. response_time: metadata[:response_time],
  37. metadata: metadata,
  38. device_type: parse_device_type(request.user_agent),
  39. browser_name: parse_browser_name(request.user_agent),
  40. os_name: parse_os_name(request.user_agent),
  41. occurred_at: Time.current
  42. )
  43. end
  44. 1 def self.parse_device_type(user_agent)
  45. return nil unless user_agent
  46. case user_agent
  47. when /tablet|ipad/i
  48. "tablet"
  49. when /mobile|android|iphone|phone/i
  50. "mobile"
  51. else
  52. "desktop"
  53. end
  54. end
  55. 1 def self.parse_browser_name(user_agent)
  56. return nil unless user_agent
  57. case user_agent
  58. when /chrome/i
  59. "Chrome"
  60. when /safari/i
  61. "Safari"
  62. when /firefox/i
  63. "Firefox"
  64. when /edge/i
  65. "Edge"
  66. when /opera/i
  67. "Opera"
  68. else
  69. "Other"
  70. end
  71. end
  72. 1 def self.parse_os_name(user_agent)
  73. return nil unless user_agent
  74. case user_agent
  75. when /windows/i
  76. "Windows"
  77. when /mac|darwin/i
  78. "macOS"
  79. when /android/i
  80. "Android"
  81. when /ios|iphone|ipad/i
  82. "iOS"
  83. when /linux/i
  84. "Linux"
  85. else
  86. "Other"
  87. end
  88. end
  89. # Instance methods
  90. 1 def suspicious?
  91. suspicious
  92. end
  93. 1 def failed?
  94. response_status && response_status >= 400
  95. end
  96. 1 def successful?
  97. response_status && response_status < 400
  98. end
  99. 1 def full_action
  100. "#{controller}##{action}"
  101. end
  102. 1 def duration_in_ms
  103. response_time ? (response_time * 1000).round(2) : nil
  104. end
  105. 1 private
  106. 1 def set_occurred_at
  107. self.occurred_at ||= Time.current
  108. end
  109. end

app/models/admin_audit_log.rb

0.0% lines covered

24 relevant lines. 0 lines covered and 24 lines missed.
    
  1. class AdminAuditLog < ApplicationRecord
  2. belongs_to :user
  3. belongs_to :auditable, polymorphic: true, optional: true
  4. validates :action, presence: true
  5. scope :recent, -> { order(created_at: :desc) }
  6. scope :by_user, ->(user) { where(user: user) }
  7. scope :by_action, ->(action) { where(action: action) }
  8. def self.log_action(user:, action:, auditable: nil, changes: nil, request: nil)
  9. create!(
  10. user: user,
  11. action: action,
  12. auditable: auditable,
  13. change_details: changes&.to_json,
  14. ip_address: request&.remote_ip,
  15. user_agent: request&.user_agent
  16. )
  17. end
  18. def parsed_changes
  19. return {} unless change_details.present?
  20. JSON.parse(change_details)
  21. rescue JSON::ParserError
  22. {}
  23. end
  24. end

app/models/application_record.rb

100.0% lines covered

2 relevant lines. 2 lines covered and 0 lines missed.
    
  1. 1 class ApplicationRecord < ActiveRecord::Base
  2. 1 primary_abstract_class
  3. end

app/models/brand.rb

74.19% lines covered

31 relevant lines. 23 lines covered and 8 lines missed.
    
  1. 1 class Brand < ApplicationRecord
  2. 1 include Branding::Compliance::CacheInvalidation
  3. 1 belongs_to :user
  4. 1 has_many :brand_assets, dependent: :destroy
  5. 1 has_many :brand_guidelines, dependent: :destroy
  6. 1 has_one :messaging_framework, dependent: :destroy
  7. 1 has_many :brand_analyses, dependent: :destroy
  8. 1 has_many :journeys
  9. 1 has_many :compliance_results, dependent: :destroy
  10. # Validations
  11. 1 validates :name, presence: true, uniqueness: { scope: :user_id }
  12. 1 validates :user, presence: true
  13. # Scopes
  14. 1 scope :active, -> { where(active: true) }
  15. 1 scope :by_industry, ->(industry) { where(industry: industry) }
  16. # Callbacks
  17. 1 after_create :create_default_messaging_framework
  18. # Methods
  19. 1 def latest_analysis
  20. brand_analyses.order(created_at: :desc).first
  21. end
  22. 1 def has_complete_brand_assets?
  23. brand_assets.where(processing_status: "completed").exists?
  24. end
  25. 1 def guidelines_by_category(category)
  26. brand_guidelines.active.where(category: category).order(priority: :desc)
  27. end
  28. 1 def primary_colors
  29. color_scheme["primary"] || []
  30. end
  31. 1 def secondary_colors
  32. color_scheme["secondary"] || []
  33. end
  34. 1 def font_families
  35. typography["font_families"] || {}
  36. end
  37. 1 def brand_voice_attributes
  38. latest_analysis&.voice_attributes || {}
  39. end
  40. 1 private
  41. 1 def create_default_messaging_framework
  42. MessagingFramework.create!(brand: self)
  43. end
  44. end

app/models/brand_analysis.rb

0.0% lines covered

63 relevant lines. 0 lines covered and 63 lines missed.
    
  1. class BrandAnalysis < ApplicationRecord
  2. include Branding::Compliance::CacheInvalidation
  3. belongs_to :brand
  4. # Constants
  5. ANALYSIS_STATUSES = %w[pending processing completed failed].freeze
  6. # Validations
  7. validates :analysis_status, inclusion: { in: ANALYSIS_STATUSES }
  8. validates :confidence_score, numericality: { in: 0..1 }, allow_nil: true
  9. # Scopes
  10. scope :completed, -> { where(analysis_status: "completed") }
  11. scope :recent, -> { order(created_at: :desc) }
  12. scope :high_confidence, -> { where("confidence_score >= ?", 0.8) }
  13. # Callbacks
  14. before_validation :set_defaults
  15. # Methods
  16. def completed?
  17. analysis_status == "completed"
  18. end
  19. def processing?
  20. analysis_status == "processing"
  21. end
  22. def failed?
  23. analysis_status == "failed"
  24. end
  25. def mark_as_processing!
  26. update!(analysis_status: "processing")
  27. end
  28. def mark_as_completed!(confidence: nil)
  29. update!(
  30. analysis_status: "completed",
  31. analyzed_at: Time.current,
  32. confidence_score: confidence
  33. )
  34. end
  35. def mark_as_failed!(error_message = nil)
  36. update!(
  37. analysis_status: "failed",
  38. analysis_notes: error_message
  39. )
  40. end
  41. def voice_formality
  42. voice_attributes.dig("formality", "level") || "neutral"
  43. end
  44. def voice_tone
  45. voice_attributes.dig("tone", "primary") || "professional"
  46. end
  47. def primary_brand_values
  48. brand_values.first(3)
  49. end
  50. def has_visual_guidelines?
  51. visual_guidelines.present? && visual_guidelines.any?
  52. end
  53. def color_palette
  54. visual_guidelines.dig("colors") || {}
  55. end
  56. def typography_rules
  57. visual_guidelines.dig("typography") || {}
  58. end
  59. private
  60. def set_defaults
  61. self.analysis_data ||= {}
  62. self.extracted_rules ||= {}
  63. self.voice_attributes ||= {}
  64. self.brand_values ||= []
  65. self.messaging_pillars ||= []
  66. self.visual_guidelines ||= {}
  67. end
  68. end

app/models/brand_asset.rb

65.12% lines covered

43 relevant lines. 28 lines covered and 15 lines missed.
    
  1. 1 class BrandAsset < ApplicationRecord
  2. 1 belongs_to :brand
  3. 1 has_one_attached :file
  4. # Constants
  5. 1 ASSET_TYPES = %w[brand_guidelines logo style_guide document image video template].freeze
  6. 1 PROCESSING_STATUSES = %w[pending processing completed failed].freeze
  7. ALLOWED_CONTENT_TYPES = {
  8. 1 document: %w[
  9. application/pdf
  10. application/msword
  11. application/vnd.openxmlformats-officedocument.wordprocessingml.document
  12. text/plain
  13. text/rtf
  14. ],
  15. image: %w[
  16. image/jpeg
  17. image/png
  18. image/gif
  19. image/svg+xml
  20. image/webp
  21. ],
  22. video: %w[
  23. video/mp4
  24. video/quicktime
  25. video/x-msvideo
  26. ],
  27. archive: %w[
  28. application/zip
  29. application/x-zip-compressed
  30. ]
  31. }.freeze
  32. # Validations
  33. 1 validates :asset_type, presence: true, inclusion: { in: ASSET_TYPES }
  34. 1 validates :processing_status, inclusion: { in: PROCESSING_STATUSES }
  35. 1 validates :file, presence: true
  36. # Scopes
  37. 1 scope :by_type, ->(type) { where(asset_type: type) }
  38. 1 scope :processed, -> { where(processing_status: "completed") }
  39. 1 scope :pending, -> { where(processing_status: "pending") }
  40. 1 scope :failed, -> { where(processing_status: "failed") }
  41. # Callbacks
  42. 1 after_create_commit :queue_processing_job, unless: -> { Rails.env.test? }
  43. # Methods
  44. 1 def document?
  45. ALLOWED_CONTENT_TYPES[:document].include?(content_type)
  46. end
  47. 1 def image?
  48. ALLOWED_CONTENT_TYPES[:image].include?(content_type)
  49. end
  50. 1 def video?
  51. ALLOWED_CONTENT_TYPES[:video].include?(content_type)
  52. end
  53. 1 def archive?
  54. ALLOWED_CONTENT_TYPES[:archive].include?(content_type)
  55. end
  56. 1 def processed?
  57. processing_status == "completed"
  58. end
  59. 1 def processing?
  60. processing_status == "processing"
  61. end
  62. 1 def failed?
  63. processing_status == "failed"
  64. end
  65. 1 def file_size_mb
  66. return 0 unless file.attached?
  67. file.blob.byte_size.to_f / 1.megabyte
  68. end
  69. 1 def content_type
  70. return nil unless file.attached?
  71. file.content_type
  72. end
  73. 1 def mark_as_processing!
  74. update!(processing_status: "processing")
  75. end
  76. 1 def mark_as_completed!
  77. update!(
  78. processing_status: "completed",
  79. processed_at: Time.current
  80. )
  81. end
  82. 1 def mark_as_failed!(error_message = nil)
  83. update!(
  84. processing_status: "failed",
  85. metadata: metadata.merge(error: error_message)
  86. )
  87. end
  88. 1 private
  89. 1 def queue_processing_job
  90. BrandAssetProcessingJob.perform_later(self)
  91. end
  92. end

app/models/brand_guideline.rb

73.33% lines covered

30 relevant lines. 22 lines covered and 8 lines missed.
    
  1. 1 class BrandGuideline < ApplicationRecord
  2. 1 include Branding::Compliance::CacheInvalidation
  3. 1 belongs_to :brand
  4. # Constants
  5. 1 RULE_TYPES = %w[do dont must should avoid prefer].freeze
  6. 1 CATEGORIES = %w[voice tone visual messaging grammar style accessibility].freeze
  7. # Validations
  8. 1 validates :rule_type, presence: true, inclusion: { in: RULE_TYPES }
  9. 1 validates :rule_content, presence: true
  10. 1 validates :category, inclusion: { in: CATEGORIES }, allow_nil: true
  11. 1 validates :priority, numericality: { greater_than_or_equal_to: 0 }
  12. # Scopes
  13. 1 scope :active, -> { where(active: true) }
  14. 1 scope :by_category, ->(category) { where(category: category) }
  15. 1 scope :by_type, ->(type) { where(rule_type: type) }
  16. 1 scope :high_priority, -> { where("priority >= ?", 7) }
  17. 1 scope :ordered, -> { order(priority: :desc, created_at: :asc) }
  18. # Methods
  19. 1 def positive_rule?
  20. %w[do must should prefer].include?(rule_type)
  21. end
  22. 1 def negative_rule?
  23. %w[dont avoid].include?(rule_type)
  24. end
  25. 1 def mandatory?
  26. %w[must dont].include?(rule_type)
  27. end
  28. 1 def suggestion?
  29. %w[should prefer avoid].include?(rule_type)
  30. end
  31. 1 def toggle_active!
  32. update!(active: !active)
  33. end
  34. # Class methods
  35. 1 def self.by_priority
  36. ordered.group_by(&:priority)
  37. end
  38. 1 def self.mandatory_rules
  39. active.where(rule_type: %w[must dont])
  40. end
  41. 1 def self.suggestions
  42. active.where(rule_type: %w[should prefer avoid])
  43. end
  44. end

app/models/campaign.rb

50.65% lines covered

77 relevant lines. 39 lines covered and 38 lines missed.
    
  1. 1 class Campaign < ApplicationRecord
  2. 1 belongs_to :user
  3. 1 belongs_to :persona
  4. 1 has_many :journeys, dependent: :destroy
  5. 1 has_many :journey_analytics, through: :journeys, class_name: 'JourneyAnalytics'
  6. 1 has_many :campaign_analytics, dependent: :destroy
  7. 1 has_many :ab_tests, dependent: :destroy
  8. 1 STATUSES = %w[draft active paused completed archived].freeze
  9. CAMPAIGN_TYPES = %w[
  10. 1 product_launch brand_awareness lead_generation customer_retention
  11. seasonal_promotion content_marketing email_nurture social_media
  12. event_promotion customer_onboarding re_engagement cross_sell
  13. upsell referral awareness consideration conversion advocacy
  14. ].freeze
  15. 1 validates :name, presence: true, uniqueness: { scope: :user_id }
  16. 1 validates :status, inclusion: { in: STATUSES }
  17. 1 validates :campaign_type, inclusion: { in: CAMPAIGN_TYPES }, allow_blank: true
  18. 1 validates :persona, presence: true
  19. 1 scope :active, -> { where(status: 'active') }
  20. 1 scope :draft, -> { where(status: 'draft') }
  21. 1 scope :completed, -> { where(status: 'completed') }
  22. 1 scope :by_type, ->(type) { where(campaign_type: type) if type.present? }
  23. 1 scope :for_persona, ->(persona_id) { where(persona_id: persona_id) if persona_id.present? }
  24. 1 scope :running, -> { where(status: ['active', 'paused']) }
  25. 1 def activate!
  26. update!(status: 'active', started_at: Time.current)
  27. end
  28. 1 def pause!
  29. update!(status: 'paused')
  30. end
  31. 1 def complete!
  32. update!(status: 'completed', ended_at: Time.current)
  33. end
  34. 1 def archive!
  35. update!(status: 'archived')
  36. end
  37. 1 def active?
  38. status == 'active'
  39. end
  40. 1 def running?
  41. %w[active paused].include?(status)
  42. end
  43. 1 def completed?
  44. status == 'completed'
  45. end
  46. 1 def duration_days
  47. return 0 unless started_at
  48. end_date = ended_at || Time.current
  49. ((end_date - started_at) / 1.day).round
  50. end
  51. 1 def total_journeys
  52. journeys.count
  53. end
  54. 1 def active_journeys
  55. journeys.published.count
  56. end
  57. 1 def performance_summary
  58. return {} unless running? || completed?
  59. {
  60. total_executions: journey_executions_count,
  61. completion_rate: completion_rate,
  62. average_duration: average_journey_duration,
  63. conversion_rate: conversion_rate,
  64. engagement_score: engagement_score
  65. }
  66. end
  67. 1 def journey_executions_count
  68. journeys.joins(:journey_executions).count
  69. end
  70. 1 def completion_rate
  71. total = journey_executions_count
  72. return 0 if total == 0
  73. completed = journeys.joins(:journey_executions)
  74. .where(journey_executions: { status: 'completed' })
  75. .count
  76. (completed.to_f / total * 100).round(1)
  77. end
  78. 1 def conversion_rate
  79. # This would be calculated based on conversion goals
  80. # For now, return completion rate as a proxy
  81. completion_rate
  82. end
  83. 1 def engagement_score
  84. # Calculate based on step engagement, feedback, etc.
  85. # For now, return a placeholder calculation
  86. return 0 unless journey_executions_count > 0
  87. # Use completion rate and feedback as basis
  88. base_score = completion_rate
  89. feedback_bonus = positive_feedback_percentage * 0.3
  90. [base_score + feedback_bonus, 100].min.round(1)
  91. end
  92. 1 def average_journey_duration
  93. executions = journeys.joins(:journey_executions)
  94. .where(journey_executions: { status: 'completed' })
  95. .where.not(journey_executions: { completed_at: nil })
  96. return 0 if executions.count == 0
  97. total_duration = executions.sum do |journey|
  98. journey.journey_executions.completed.sum do |execution|
  99. execution.completed_at - execution.started_at
  100. end
  101. end
  102. (total_duration / executions.count / 1.day).round(1)
  103. end
  104. 1 def positive_feedback_percentage
  105. total_feedback = journeys.joins(:suggestion_feedbacks).count
  106. return 0 if total_feedback == 0
  107. positive_feedback = journeys.joins(:suggestion_feedbacks)
  108. .where(suggestion_feedbacks: { rating: 4..5 })
  109. .count
  110. (positive_feedback.to_f / total_feedback * 100).round(1)
  111. end
  112. 1 def target_audience_context
  113. persona.to_campaign_context
  114. end
  115. 1 def progress_percentage
  116. return 0 unless total_journeys > 0
  117. (active_journeys.to_f / total_journeys * 100).round
  118. end
  119. 1 def to_analytics_context
  120. {
  121. id: id,
  122. name: name,
  123. type: campaign_type,
  124. persona: persona.name,
  125. status: status,
  126. duration_days: duration_days,
  127. performance: performance_summary,
  128. journeys_count: total_journeys
  129. }
  130. end
  131. end

app/models/compliance_result.rb

0.0% lines covered

51 relevant lines. 0 lines covered and 51 lines missed.
    
  1. class ComplianceResult < ApplicationRecord
  2. belongs_to :brand
  3. # Validations
  4. validates :content_type, presence: true
  5. validates :content_hash, presence: true
  6. validates :score, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 1 }
  7. validates :violations_count, numericality: { greater_than_or_equal_to: 0 }
  8. # Scopes
  9. scope :compliant, -> { where(compliant: true) }
  10. scope :non_compliant, -> { where(compliant: false) }
  11. scope :recent, -> { order(created_at: :desc) }
  12. scope :by_content_type, ->(type) { where(content_type: type) }
  13. scope :high_score, -> { where("score >= ?", 0.9) }
  14. scope :low_score, -> { where("score < ?", 0.5) }
  15. # Class methods
  16. def self.average_score
  17. average(:score) || 0.0
  18. end
  19. def self.compliance_rate
  20. return 0.0 if count == 0
  21. (compliant.count.to_f / count * 100).round(2)
  22. end
  23. def self.common_violations(limit = 10)
  24. all_violations = pluck(:violations_data).flatten
  25. violation_counts = Hash.new(0)
  26. all_violations.each do |violation|
  27. key = violation["type"] || violation[:type]
  28. violation_counts[key] += 1 if key
  29. end
  30. violation_counts.sort_by { |_, count| -count }.first(limit).to_h
  31. end
  32. # Instance methods
  33. def high_severity_violations
  34. violations_data.select { |v| %w[critical high].include?(v["severity"] || v[:severity]) }
  35. end
  36. def violation_summary
  37. violations_by_type = violations_data.group_by { |v| v["type"] || v[:type] }
  38. violations_by_type.transform_values(&:count)
  39. end
  40. def suggested_actions
  41. suggestions_data.select { |s| (s["priority"] || s[:priority]) == "high" }
  42. end
  43. def processing_time_seconds
  44. metadata&.dig("processing_time") || 0
  45. end
  46. def validators_used
  47. metadata&.dig("validators_used") || []
  48. end
  49. def cache_efficiency
  50. cache_hits = metadata&.dig("cache_hits") || 0
  51. total_validators = validators_used.length
  52. return 0.0 if total_validators == 0
  53. (cache_hits.to_f / total_validators * 100).round(2)
  54. end
  55. end

app/models/concerns/branding/compliance/cache_invalidation.rb

53.33% lines covered

15 relevant lines. 8 lines covered and 7 lines missed.
    
  1. 1 module Branding
  2. 1 module Compliance
  3. 1 module CacheInvalidation
  4. 1 extend ActiveSupport::Concern
  5. 1 included do
  6. 2 after_commit :invalidate_compliance_cache, on: [:create, :update, :destroy]
  7. end
  8. 1 private
  9. 1 def invalidate_compliance_cache
  10. # Skip cache invalidation in test environment to avoid job issues
  11. return if Rails.env.test?
  12. brand_id = case self
  13. when Brand then id
  14. when BrandGuideline, BrandAnalysis then brand_id
  15. else return
  16. end
  17. # Use the CacheService to invalidate rules
  18. Branding::Compliance::CacheService.invalidate_rules(brand_id)
  19. # Queue cache warming to rebuild cache
  20. Branding::Compliance::CacheWarmerJob.perform_later(brand_id)
  21. end
  22. end
  23. end
  24. end

app/models/conversion_funnel.rb

0.0% lines covered

171 relevant lines. 0 lines covered and 171 lines missed.
    
  1. class ConversionFunnel < ApplicationRecord
  2. belongs_to :journey
  3. belongs_to :campaign
  4. belongs_to :user
  5. validates :funnel_name, presence: true
  6. validates :stage, presence: true
  7. validates :stage_order, presence: true, uniqueness: { scope: [:journey_id, :funnel_name, :period_start] }
  8. validates :visitors, presence: true, numericality: { greater_than_or_equal_to: 0 }
  9. validates :conversions, presence: true, numericality: { greater_than_or_equal_to: 0 }
  10. validates :conversion_rate, presence: true, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100 }
  11. validates :drop_off_rate, presence: true, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100 }
  12. validates :period_start, presence: true
  13. validates :period_end, presence: true
  14. validate :period_end_after_start
  15. validate :conversions_not_exceed_visitors
  16. scope :by_funnel, ->(funnel_name) { where(funnel_name: funnel_name) }
  17. scope :by_stage, ->(stage) { where(stage: stage) }
  18. scope :ordered_by_stage, -> { order(:stage_order) }
  19. scope :for_period, ->(start_date, end_date) { where(period_start: start_date..end_date) }
  20. scope :recent, -> { order(period_start: :desc) }
  21. scope :high_conversion, -> { where('conversion_rate > ?', 20.0) }
  22. scope :high_drop_off, -> { where('drop_off_rate > ?', 50.0) }
  23. # Common funnel stages for marketing journeys
  24. AWARENESS_STAGES = %w[impression reach view].freeze
  25. CONSIDERATION_STAGES = %w[click engage explore read].freeze
  26. CONVERSION_STAGES = %w[signup purchase subscribe convert].freeze
  27. RETENTION_STAGES = %w[login return repeat_purchase loyalty].freeze
  28. ADVOCACY_STAGES = %w[share recommend review refer].freeze
  29. ALL_STAGES = (AWARENESS_STAGES + CONSIDERATION_STAGES +
  30. CONVERSION_STAGES + RETENTION_STAGES + ADVOCACY_STAGES).freeze
  31. def self.create_journey_funnel(journey, period_start, period_end, funnel_name = 'default')
  32. # Create funnel stages based on journey steps
  33. journey.journey_steps.order(:position).each_with_index do |step, index|
  34. create!(
  35. journey: journey,
  36. campaign: journey.campaign,
  37. user: journey.user,
  38. funnel_name: funnel_name,
  39. stage: step.stage,
  40. stage_order: index + 1,
  41. period_start: period_start,
  42. period_end: period_end
  43. )
  44. end
  45. end
  46. def self.calculate_funnel_metrics(journey_id, funnel_name, period_start, period_end)
  47. funnel_stages = where(journey_id: journey_id, funnel_name: funnel_name)
  48. .where(period_start: period_start, period_end: period_end)
  49. .ordered_by_stage
  50. return [] if funnel_stages.empty?
  51. # Calculate visitors and conversions for each stage
  52. funnel_stages.each_with_index do |stage, index|
  53. if index == 0
  54. # First stage - visitors are the total who entered the journey
  55. stage.update!(
  56. visitors: calculate_stage_visitors(stage),
  57. conversions: calculate_stage_conversions(stage)
  58. )
  59. else
  60. # Subsequent stages - visitors are conversions from previous stage
  61. previous_stage = funnel_stages[index - 1]
  62. stage.update!(
  63. visitors: previous_stage.conversions,
  64. conversions: calculate_stage_conversions(stage)
  65. )
  66. end
  67. # Calculate rates
  68. stage.update!(
  69. conversion_rate: stage.visitors > 0 ? (stage.conversions.to_f / stage.visitors * 100).round(2) : 0,
  70. drop_off_rate: stage.visitors > 0 ? ((stage.visitors - stage.conversions).to_f / stage.visitors * 100).round(2) : 0
  71. )
  72. end
  73. funnel_stages.reload
  74. end
  75. def self.funnel_overview(journey_id, funnel_name, period_start, period_end)
  76. stages = by_funnel(funnel_name)
  77. .where(journey_id: journey_id)
  78. .where(period_start: period_start, period_end: period_end)
  79. .ordered_by_stage
  80. return {} if stages.empty?
  81. total_visitors = stages.first.visitors
  82. final_conversions = stages.last.conversions
  83. overall_conversion_rate = total_visitors > 0 ? (final_conversions.to_f / total_visitors * 100).round(2) : 0
  84. {
  85. funnel_name: funnel_name,
  86. total_visitors: total_visitors,
  87. final_conversions: final_conversions,
  88. overall_conversion_rate: overall_conversion_rate,
  89. total_stages: stages.count,
  90. biggest_drop_off_stage: stages.max_by(&:drop_off_rate)&.stage,
  91. best_converting_stage: stages.max_by(&:conversion_rate)&.stage,
  92. stages: stages.map(&:to_funnel_data)
  93. }
  94. end
  95. def self.compare_funnels(journey_id, period1_start, period1_end, period2_start, period2_end, funnel_name = 'default')
  96. period1_data = funnel_overview(journey_id, funnel_name, period1_start, period1_end)
  97. period2_data = funnel_overview(journey_id, funnel_name, period2_start, period2_end)
  98. return {} if period1_data.empty? || period2_data.empty?
  99. {
  100. period1: period1_data,
  101. period2: period2_data,
  102. comparison: {
  103. visitor_change: period2_data[:total_visitors] - period1_data[:total_visitors],
  104. conversion_change: period2_data[:final_conversions] - period1_data[:final_conversions],
  105. rate_change: period2_data[:overall_conversion_rate] - period1_data[:overall_conversion_rate]
  106. }
  107. }
  108. end
  109. def to_funnel_data
  110. {
  111. stage: stage,
  112. stage_order: stage_order,
  113. visitors: visitors,
  114. conversions: conversions,
  115. conversion_rate: conversion_rate,
  116. drop_off_rate: drop_off_rate,
  117. drop_off_count: visitors - conversions
  118. }
  119. end
  120. def next_stage
  121. self.class.where(journey_id: journey_id, funnel_name: funnel_name, period_start: period_start)
  122. .where(stage_order: stage_order + 1)
  123. .first
  124. end
  125. def previous_stage
  126. self.class.where(journey_id: journey_id, funnel_name: funnel_name, period_start: period_start)
  127. .where(stage_order: stage_order - 1)
  128. .first
  129. end
  130. def optimization_suggestions
  131. suggestions = []
  132. if drop_off_rate > 70
  133. suggestions << "High drop-off rate (#{drop_off_rate}%) - consider improving #{stage} experience"
  134. end
  135. if conversion_rate < 10 && stage_order > 1
  136. suggestions << "Low conversion rate (#{conversion_rate}%) - optimize #{stage} messaging or incentives"
  137. end
  138. if next_stage && next_stage.visitors < (conversions * 0.8)
  139. suggestions << "Significant visitor loss between #{stage} and #{next_stage.stage} - check journey flow"
  140. end
  141. suggestions.empty? ? ["Performance looks good for #{stage} stage"] : suggestions
  142. end
  143. private
  144. def period_end_after_start
  145. return unless period_start && period_end
  146. errors.add(:period_end, 'must be after period start') if period_end <= period_start
  147. end
  148. def conversions_not_exceed_visitors
  149. return unless visitors && conversions
  150. errors.add(:conversions, 'cannot exceed visitors') if conversions > visitors
  151. end
  152. def self.calculate_stage_visitors(stage)
  153. # This would integrate with actual execution data
  154. # For now, return a placeholder calculation based on journey executions
  155. journey = stage.journey
  156. executions_in_period = journey.journey_executions
  157. .where(created_at: stage.period_start..stage.period_end)
  158. # Count executions that reached this stage
  159. stage_step = journey.journey_steps.find_by(stage: stage.stage)
  160. return 0 unless stage_step
  161. executions_in_period.joins(:step_executions)
  162. .where(step_executions: { journey_step_id: stage_step.id })
  163. .distinct
  164. .count
  165. end
  166. def self.calculate_stage_conversions(stage)
  167. # This would integrate with actual execution data
  168. # For now, return a placeholder calculation based on completed step executions
  169. journey = stage.journey
  170. executions_in_period = journey.journey_executions
  171. .where(created_at: stage.period_start..stage.period_end)
  172. # Count executions that completed this stage
  173. stage_step = journey.journey_steps.find_by(stage: stage.stage)
  174. return 0 unless stage_step
  175. executions_in_period.joins(:step_executions)
  176. .where(step_executions: {
  177. journey_step_id: stage_step.id,
  178. status: 'completed'
  179. })
  180. .distinct
  181. .count
  182. end
  183. end

app/models/current.rb

0.0% lines covered

8 relevant lines. 0 lines covered and 8 lines missed.
    
  1. class Current < ActiveSupport::CurrentAttributes
  2. attribute :session
  3. attribute :user_agent
  4. attribute :ip_address
  5. attribute :request_id
  6. attribute :session_id
  7. delegate :user, to: :session, allow_nil: true
  8. end

app/models/journey.rb

38.35% lines covered

133 relevant lines. 51 lines covered and 82 lines missed.
    
  1. 1 class Journey < ApplicationRecord
  2. 1 belongs_to :user
  3. 1 belongs_to :campaign, optional: true
  4. 1 belongs_to :brand, optional: true
  5. 1 has_one :persona, through: :campaign
  6. 1 has_many :journey_steps, dependent: :destroy
  7. 1 has_many :step_transitions, through: :journey_steps
  8. 1 has_many :journey_executions, dependent: :destroy
  9. 1 has_many :suggestion_feedbacks, dependent: :destroy
  10. 1 has_many :journey_insights, dependent: :destroy
  11. 1 has_many :journey_analytics, class_name: 'JourneyAnalytics', dependent: :destroy
  12. 1 has_many :conversion_funnels, dependent: :destroy
  13. 1 has_many :journey_metrics, dependent: :destroy
  14. 1 has_many :ab_test_variants, dependent: :destroy
  15. 1 has_many :ab_tests, through: :ab_test_variants
  16. 1 STATUSES = %w[draft published archived].freeze
  17. CAMPAIGN_TYPES = %w[
  18. 1 product_launch
  19. brand_awareness
  20. lead_generation
  21. customer_retention
  22. seasonal_promotion
  23. content_marketing
  24. email_nurture
  25. social_media
  26. event_promotion
  27. custom
  28. ].freeze
  29. 1 STAGES = %w[awareness consideration conversion retention advocacy].freeze
  30. 1 validates :name, presence: true
  31. 1 validates :status, inclusion: { in: STATUSES }
  32. 1 validates :campaign_type, inclusion: { in: CAMPAIGN_TYPES }, allow_blank: true
  33. 1 scope :draft, -> { where(status: 'draft') }
  34. 1 scope :published, -> { where(status: 'published') }
  35. 1 scope :archived, -> { where(status: 'archived') }
  36. 1 scope :active, -> { where(status: %w[draft published]) }
  37. 1 def publish!
  38. update!(status: 'published', published_at: Time.current)
  39. end
  40. 1 def archive!
  41. update!(status: 'archived', archived_at: Time.current)
  42. end
  43. 1 def published?
  44. status == 'published'
  45. end
  46. 1 def duplicate
  47. dup.tap do |new_journey|
  48. new_journey.name = "#{name} (Copy)"
  49. new_journey.status = 'draft'
  50. new_journey.published_at = nil
  51. new_journey.archived_at = nil
  52. new_journey.save!
  53. journey_steps.each do |step|
  54. new_step = step.dup
  55. new_step.journey = new_journey
  56. new_step.save!
  57. end
  58. end
  59. end
  60. 1 def total_steps
  61. journey_steps.count
  62. end
  63. 1 def steps_by_stage
  64. journey_steps.group(:stage).count
  65. end
  66. 1 def to_json_export
  67. {
  68. name: name,
  69. description: description,
  70. campaign_type: campaign_type,
  71. target_audience: target_audience,
  72. goals: goals,
  73. metadata: metadata,
  74. settings: settings,
  75. steps: journey_steps.includes(:transitions_from, :transitions_to).map(&:to_json_export)
  76. }
  77. end
  78. # Analytics methods
  79. 1 def current_analytics(period = 'daily')
  80. journey_analytics.order(period_start: :desc).first
  81. end
  82. 1 def analytics_summary(days = 30)
  83. start_date = days.days.ago
  84. end_date = Time.current
  85. analytics = journey_analytics.where(period_start: start_date..end_date)
  86. return {} if analytics.empty?
  87. {
  88. total_executions: analytics.sum(:total_executions),
  89. completed_executions: analytics.sum(:completed_executions),
  90. abandoned_executions: analytics.sum(:abandoned_executions),
  91. average_conversion_rate: analytics.average(:conversion_rate)&.round(2) || 0,
  92. average_engagement_score: analytics.average(:engagement_score)&.round(2) || 0,
  93. period_days: days
  94. }
  95. end
  96. 1 def funnel_performance(funnel_name = 'default', days = 7)
  97. start_date = days.days.ago
  98. end_date = Time.current
  99. ConversionFunnel.funnel_overview(id, funnel_name, start_date, end_date)
  100. end
  101. 1 def compare_with_journey(other_journey_id, metrics = JourneyMetrics::CORE_METRICS)
  102. JourneyMetrics.compare_journey_metrics(id, other_journey_id, metrics)
  103. end
  104. 1 def performance_trends(periods = 7)
  105. JourneyAnalytics.calculate_trends(id, periods)
  106. end
  107. 1 def is_ab_test_variant?
  108. ab_test_variants.any?
  109. end
  110. 1 def ab_test_status
  111. return 'not_in_test' unless is_ab_test_variant?
  112. test = ab_tests.active.first
  113. return 'no_active_test' unless test
  114. variant = ab_test_variants.joins(:ab_test).where(ab_tests: { id: test.id }).first
  115. return 'unknown_variant' unless variant
  116. {
  117. test_name: test.name,
  118. variant_name: variant.name,
  119. is_control: variant.is_control?,
  120. test_status: test.status,
  121. traffic_percentage: variant.traffic_percentage
  122. }
  123. end
  124. 1 def persona_context
  125. return {} unless campaign&.persona
  126. campaign.persona.to_campaign_context
  127. end
  128. 1 def campaign_context
  129. return {} unless campaign
  130. campaign.to_analytics_context
  131. end
  132. 1 def calculate_metrics!(period = 'daily')
  133. JourneyMetrics.calculate_and_store_metrics(self, period)
  134. end
  135. 1 def create_conversion_funnel!(period_start = 1.week.ago, period_end = Time.current, funnel_name = 'default')
  136. ConversionFunnel.create_journey_funnel(self, period_start, period_end, funnel_name)
  137. ConversionFunnel.calculate_funnel_metrics(id, funnel_name, period_start, period_end)
  138. end
  139. 1 def latest_performance_score
  140. latest_analytics = current_analytics
  141. return 0 unless latest_analytics
  142. # Weighted performance score
  143. conversion_weight = 0.4
  144. engagement_weight = 0.3
  145. completion_weight = 0.3
  146. (latest_analytics.conversion_rate * conversion_weight +
  147. latest_analytics.engagement_score * engagement_weight +
  148. (latest_analytics.completed_executions.to_f / [latest_analytics.total_executions, 1].max * 100) * completion_weight).round(1)
  149. end
  150. # Brand compliance analytics methods
  151. 1 def brand_compliance_summary(days = 30)
  152. return {} unless brand_id.present?
  153. JourneyInsight.brand_compliance_summary(id, days)
  154. end
  155. 1 def brand_compliance_by_step(days = 30)
  156. return {} unless brand_id.present?
  157. JourneyInsight.brand_compliance_by_step(id, days)
  158. end
  159. 1 def brand_violations_breakdown(days = 30)
  160. return {} unless brand_id.present?
  161. JourneyInsight.brand_violations_breakdown(id, days)
  162. end
  163. 1 def latest_brand_compliance_score
  164. return 1.0 unless brand_id.present?
  165. latest_compliance = journey_insights
  166. .brand_compliance
  167. .order(calculated_at: :desc)
  168. .first
  169. latest_compliance&.data&.dig('score') || 1.0
  170. end
  171. 1 def brand_compliance_trend(days = 30)
  172. return 'stable' unless brand_id.present?
  173. compliance_insights = journey_insights
  174. .brand_compliance
  175. .where('calculated_at >= ?', days.days.ago)
  176. .order(calculated_at: :desc)
  177. return 'stable' if compliance_insights.count < 3
  178. scores = compliance_insights.map { |insight| insight.data['score'] }.compact
  179. JourneyInsight.calculate_score_trend(scores)
  180. end
  181. 1 def overall_brand_health_score
  182. return 1.0 unless brand_id.present?
  183. compliance_summary = brand_compliance_summary(30)
  184. return 1.0 if compliance_summary.empty?
  185. # Calculate overall brand health based on multiple factors
  186. compliance_score = compliance_summary[:average_score] || 1.0
  187. compliance_rate = (compliance_summary[:compliance_rate] || 100) / 100.0
  188. violation_penalty = [compliance_summary[:total_violations] * 0.05, 0.5].min
  189. # Weighted brand health score
  190. health_score = (compliance_score * 0.6) + (compliance_rate * 0.4) - violation_penalty
  191. [health_score, 0.0].max.round(3)
  192. end
  193. 1 def brand_compliance_alerts
  194. return [] unless brand_id.present?
  195. alerts = []
  196. summary = brand_compliance_summary(7) # Last 7 days
  197. if summary.present?
  198. # Alert for low average score
  199. if summary[:average_score] < 0.7
  200. alerts << {
  201. type: 'low_compliance_score',
  202. severity: 'high',
  203. message: "Average brand compliance score is #{(summary[:average_score] * 100).round(1)}%",
  204. recommendation: 'Review content against brand guidelines'
  205. }
  206. end
  207. # Alert for declining trend
  208. if brand_compliance_trend(7) == 'declining'
  209. alerts << {
  210. type: 'declining_compliance',
  211. severity: 'medium',
  212. message: 'Brand compliance trend is declining',
  213. recommendation: 'Investigate recent content changes'
  214. }
  215. end
  216. # Alert for high violation count
  217. if summary[:total_violations] > 10
  218. alerts << {
  219. type: 'high_violations',
  220. severity: 'medium',
  221. message: "#{summary[:total_violations]} brand violations in the last 7 days",
  222. recommendation: 'Review and fix flagged content'
  223. }
  224. end
  225. end
  226. alerts
  227. end
  228. end

app/models/journey_analytics.rb

40.74% lines covered

81 relevant lines. 33 lines covered and 48 lines missed.
    
  1. 1 class JourneyAnalytics < ApplicationRecord
  2. 1 belongs_to :journey
  3. 1 belongs_to :campaign
  4. 1 belongs_to :user
  5. 1 validates :period_start, presence: true
  6. 1 validates :period_end, presence: true
  7. 1 validates :total_executions, presence: true, numericality: { greater_than_or_equal_to: 0 }
  8. 1 validates :completed_executions, presence: true, numericality: { greater_than_or_equal_to: 0 }
  9. 1 validates :abandoned_executions, presence: true, numericality: { greater_than_or_equal_to: 0 }
  10. 1 validates :conversion_rate, presence: true, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100 }
  11. 1 validates :engagement_score, presence: true, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 100 }
  12. 1 validate :period_end_after_start
  13. 1 validate :executions_consistency
  14. 1 scope :for_period, ->(start_date, end_date) { where(period_start: start_date..end_date) }
  15. 1 scope :recent, -> { order(period_start: :desc) }
  16. 1 scope :high_conversion, -> { where('conversion_rate > ?', 10.0) }
  17. 1 scope :low_engagement, -> { where('engagement_score < ?', 50.0) }
  18. # Time period scopes
  19. 1 scope :daily, -> { where('julianday(period_end) - julianday(period_start) <= ?', 1.0) }
  20. 1 scope :weekly, -> { where('julianday(period_end) - julianday(period_start) <= ?', 7.0) }
  21. 1 scope :monthly, -> { where('julianday(period_end) - julianday(period_start) <= ?', 30.0) }
  22. 1 def period_duration_days
  23. ((period_end - period_start) / 1.day).round(1)
  24. end
  25. 1 def completion_rate
  26. return 0.0 if total_executions == 0
  27. (completed_executions.to_f / total_executions * 100).round(2)
  28. end
  29. 1 def abandonment_rate
  30. return 0.0 if total_executions == 0
  31. (abandoned_executions.to_f / total_executions * 100).round(2)
  32. end
  33. 1 def average_completion_time_formatted
  34. return 'N/A' if average_completion_time == 0
  35. hours = (average_completion_time / 1.hour).to_i
  36. minutes = ((average_completion_time % 1.hour) / 1.minute).to_i
  37. if hours > 0
  38. "#{hours}h #{minutes}m"
  39. else
  40. "#{minutes}m"
  41. end
  42. end
  43. 1 def performance_grade
  44. score = (conversion_rate + engagement_score) / 2
  45. case score
  46. when 80..100 then 'A'
  47. when 65..79 then 'B'
  48. when 50..64 then 'C'
  49. when 35..49 then 'D'
  50. else 'F'
  51. end
  52. end
  53. 1 def self.aggregate_for_period(journey_id, start_date, end_date)
  54. analytics = where(journey_id: journey_id)
  55. .where(period_start: start_date..end_date)
  56. return nil if analytics.empty?
  57. {
  58. total_executions: analytics.sum(:total_executions),
  59. completed_executions: analytics.sum(:completed_executions),
  60. abandoned_executions: analytics.sum(:abandoned_executions),
  61. average_conversion_rate: analytics.average(:conversion_rate)&.round(2) || 0,
  62. average_engagement_score: analytics.average(:engagement_score)&.round(2) || 0,
  63. total_period_days: ((end_date - start_date) / 1.day).round,
  64. data_points: analytics.count
  65. }
  66. end
  67. 1 def self.calculate_trends(journey_id, periods = 4)
  68. recent_analytics = where(journey_id: journey_id)
  69. .order(period_start: :desc)
  70. .limit(periods)
  71. return {} if recent_analytics.count < 2
  72. conversion_trend = calculate_trend(recent_analytics.pluck(:conversion_rate))
  73. engagement_trend = calculate_trend(recent_analytics.pluck(:engagement_score))
  74. execution_trend = calculate_trend(recent_analytics.pluck(:total_executions))
  75. {
  76. conversion_rate: {
  77. trend: conversion_trend[:direction],
  78. change_percentage: conversion_trend[:change_percentage]
  79. },
  80. engagement_score: {
  81. trend: engagement_trend[:direction],
  82. change_percentage: engagement_trend[:change_percentage]
  83. },
  84. total_executions: {
  85. trend: execution_trend[:direction],
  86. change_percentage: execution_trend[:change_percentage]
  87. }
  88. }
  89. end
  90. 1 def compare_with_previous_period
  91. previous_analytics = self.class.where(journey_id: journey_id)
  92. .where('period_end <= ?', period_start)
  93. .order(period_end: :desc)
  94. .first
  95. return nil unless previous_analytics
  96. {
  97. conversion_rate_change: conversion_rate - previous_analytics.conversion_rate,
  98. engagement_score_change: engagement_score - previous_analytics.engagement_score,
  99. execution_change: total_executions - previous_analytics.total_executions,
  100. completion_rate_change: completion_rate - previous_analytics.completion_rate
  101. }
  102. end
  103. 1 def to_chart_data
  104. {
  105. period: period_start.strftime('%Y-%m-%d'),
  106. conversion_rate: conversion_rate,
  107. engagement_score: engagement_score,
  108. total_executions: total_executions,
  109. completion_rate: completion_rate,
  110. abandonment_rate: abandonment_rate
  111. }
  112. end
  113. 1 private
  114. 1 def period_end_after_start
  115. return unless period_start && period_end
  116. errors.add(:period_end, 'must be after period start') if period_end <= period_start
  117. end
  118. 1 def executions_consistency
  119. return unless total_executions && completed_executions && abandoned_executions
  120. if completed_executions + abandoned_executions > total_executions
  121. errors.add(:base, 'Completed and abandoned executions cannot exceed total executions')
  122. end
  123. end
  124. 1 def self.calculate_trend(values)
  125. return { direction: :stable, change_percentage: 0 } if values.length < 2
  126. # Simple linear trend calculation
  127. first_value = values.last.to_f # oldest value
  128. last_value = values.first.to_f # newest value
  129. return { direction: :stable, change_percentage: 0 } if first_value == 0
  130. change_percentage = ((last_value - first_value) / first_value * 100).round(1)
  131. direction = if change_percentage > 5
  132. :up
  133. elsif change_percentage < -5
  134. :down
  135. else
  136. :stable
  137. end
  138. {
  139. direction: direction,
  140. change_percentage: change_percentage.abs
  141. }
  142. end
  143. end

app/models/journey_execution.rb

0.0% lines covered

132 relevant lines. 0 lines covered and 132 lines missed.
    
  1. class JourneyExecution < ApplicationRecord
  2. include AASM
  3. belongs_to :journey
  4. belongs_to :user
  5. belongs_to :current_step, class_name: 'JourneyStep', optional: true
  6. has_many :step_executions, dependent: :destroy
  7. validates :user_id, uniqueness: { scope: :journey_id, message: "can only have one execution per journey" }
  8. scope :active, -> { where(status: %w[initialized running paused]) }
  9. scope :completed, -> { where(status: 'completed') }
  10. scope :failed, -> { where(status: 'failed') }
  11. aasm column: :status do
  12. state :initialized, initial: true
  13. state :running
  14. state :paused
  15. state :completed
  16. state :failed
  17. state :cancelled
  18. event :start do
  19. transitions from: [:initialized, :paused], to: :running do
  20. guard { journey.published? }
  21. after { record_start_time }
  22. end
  23. end
  24. event :pause do
  25. transitions from: :running, to: :paused do
  26. after { record_pause_time }
  27. end
  28. end
  29. event :resume do
  30. transitions from: :paused, to: :running do
  31. after { clear_pause_time }
  32. end
  33. end
  34. event :complete do
  35. transitions from: [:running, :paused], to: :completed do
  36. after { record_completion_time }
  37. end
  38. end
  39. event :fail do
  40. transitions from: [:initialized, :running, :paused], to: :failed do
  41. after { record_failure }
  42. end
  43. end
  44. event :cancel do
  45. transitions from: [:initialized, :running, :paused], to: :cancelled
  46. end
  47. event :reset do
  48. transitions from: [:completed, :failed, :cancelled], to: :initialized do
  49. after { reset_execution_state }
  50. end
  51. end
  52. end
  53. def next_step
  54. return journey.journey_steps.entry_points.first if current_step.nil?
  55. # Find next step based on transitions and conditions
  56. available_transitions = current_step.transitions_from.includes(:to_step)
  57. available_transitions.each do |transition|
  58. if transition.evaluate(execution_context)
  59. return transition.to_step
  60. end
  61. end
  62. # If no conditional transitions match, return sequential next step
  63. journey.journey_steps.where(position: current_step.position + 1).first
  64. end
  65. def advance_to_next_step!
  66. next_step_obj = next_step
  67. if next_step_obj
  68. update!(current_step: next_step_obj)
  69. create_step_execution(next_step_obj)
  70. # Check if this is an exit point
  71. complete! if next_step_obj.is_exit_point?
  72. else
  73. # No more steps available
  74. complete!
  75. end
  76. end
  77. def can_advance?
  78. return false unless running?
  79. return false if current_step&.is_exit_point?
  80. next_step.present?
  81. end
  82. def progress_percentage
  83. return 0 if journey.total_steps == 0
  84. return 100 if completed?
  85. current_position = current_step&.position || 0
  86. ((current_position.to_f / journey.total_steps) * 100).round(1)
  87. end
  88. def elapsed_time
  89. return 0 unless started_at
  90. end_time = completed_at || paused_at || Time.current
  91. end_time - started_at
  92. end
  93. def add_context(key, value)
  94. context = execution_context.dup
  95. context[key.to_s] = value
  96. update!(execution_context: context)
  97. end
  98. def get_context(key)
  99. execution_context[key.to_s]
  100. end
  101. private
  102. def record_start_time
  103. update!(started_at: Time.current) if started_at.nil?
  104. end
  105. def record_pause_time
  106. update!(paused_at: Time.current)
  107. end
  108. def clear_pause_time
  109. update!(paused_at: nil)
  110. end
  111. def record_completion_time
  112. update!(completed_at: Time.current, paused_at: nil)
  113. end
  114. def record_failure
  115. add_context('failure_time', Time.current)
  116. add_context('failure_step', current_step&.name)
  117. end
  118. def reset_execution_state
  119. update!(
  120. current_step: nil,
  121. started_at: nil,
  122. completed_at: nil,
  123. paused_at: nil,
  124. execution_context: {},
  125. completion_notes: nil
  126. )
  127. step_executions.destroy_all
  128. end
  129. def create_step_execution(step)
  130. step_executions.create!(
  131. journey_step: step,
  132. started_at: Time.current,
  133. context: execution_context.dup
  134. )
  135. end
  136. end

app/models/journey_insight.rb

0.0% lines covered

313 relevant lines. 0 lines covered and 313 lines missed.
    
  1. class JourneyInsight < ApplicationRecord
  2. belongs_to :journey
  3. INSIGHTS_TYPES = %w[
  4. ai_suggestions
  5. performance_metrics
  6. user_behavior
  7. completion_rates
  8. stage_effectiveness
  9. content_performance
  10. channel_performance
  11. optimization_opportunities
  12. predictive_analytics
  13. benchmark_comparison
  14. brand_compliance
  15. brand_voice_analysis
  16. brand_guideline_adherence
  17. ].freeze
  18. validates :insights_type, inclusion: { in: INSIGHTS_TYPES }
  19. validates :calculated_at, presence: true
  20. scope :active, -> { where('expires_at IS NULL OR expires_at > ?', Time.current) }
  21. scope :expired, -> { where('expires_at IS NOT NULL AND expires_at <= ?', Time.current) }
  22. scope :by_type, ->(type) { where(insights_type: type) }
  23. scope :recent, ->(days = 7) { where('calculated_at >= ?', days.days.ago) }
  24. # Scopes for different insights types
  25. scope :ai_suggestions, -> { by_type('ai_suggestions') }
  26. scope :performance_metrics, -> { by_type('performance_metrics') }
  27. scope :user_behavior, -> { by_type('user_behavior') }
  28. scope :brand_compliance, -> { by_type('brand_compliance') }
  29. scope :brand_voice_analysis, -> { by_type('brand_voice_analysis') }
  30. scope :brand_guideline_adherence, -> { by_type('brand_guideline_adherence') }
  31. # Class methods for analytics
  32. def self.latest_for_journey(journey_id, insights_type = nil)
  33. query = where(journey_id: journey_id).active.order(calculated_at: :desc)
  34. query = query.by_type(insights_type) if insights_type
  35. query.first
  36. end
  37. def self.insights_summary_for_journey(journey_id)
  38. where(journey_id: journey_id)
  39. .active
  40. .group(:insights_type)
  41. .maximum(:calculated_at)
  42. .transform_values { |timestamp| where(journey_id: journey_id, calculated_at: timestamp) }
  43. end
  44. def self.cleanup_expired
  45. expired.delete_all
  46. end
  47. def self.refresh_stale_insights(threshold = 24.hours)
  48. where('calculated_at < ?', threshold.ago).delete_all
  49. end
  50. # Brand compliance analytics class methods
  51. def self.brand_compliance_summary(journey_id, days = 30)
  52. compliance_insights = where(journey_id: journey_id)
  53. .brand_compliance
  54. .where('calculated_at >= ?', days.days.ago)
  55. .order(calculated_at: :desc)
  56. return {} if compliance_insights.empty?
  57. scores = compliance_insights.map { |insight| insight.data['score'] }.compact
  58. violations_counts = compliance_insights.map { |insight| insight.data['violations_count'] || 0 }
  59. {
  60. average_score: scores.sum.to_f / scores.length,
  61. latest_score: scores.first,
  62. score_trend: calculate_score_trend(scores),
  63. total_violations: violations_counts.sum,
  64. average_violations_per_check: violations_counts.sum.to_f / violations_counts.length,
  65. checks_performed: compliance_insights.count,
  66. compliant_checks: compliance_insights.count { |insight| insight.data['compliant'] },
  67. compliance_rate: compliance_insights.count { |insight| insight.data['compliant'] }.to_f / compliance_insights.count * 100
  68. }
  69. end
  70. def self.brand_compliance_by_step(journey_id, days = 30)
  71. compliance_insights = where(journey_id: journey_id)
  72. .brand_compliance
  73. .where('calculated_at >= ?', days.days.ago)
  74. step_compliance = {}
  75. compliance_insights.each do |insight|
  76. step_id = insight.data['step_id']
  77. next unless step_id
  78. step_compliance[step_id] ||= {
  79. scores: [],
  80. violations: [],
  81. checks: 0
  82. }
  83. step_compliance[step_id][:scores] << insight.data['score']
  84. step_compliance[step_id][:violations] << (insight.data['violations_count'] || 0)
  85. step_compliance[step_id][:checks] += 1
  86. end
  87. # Calculate averages for each step
  88. step_compliance.transform_values do |data|
  89. {
  90. average_score: data[:scores].sum.to_f / data[:scores].length,
  91. total_violations: data[:violations].sum,
  92. checks_performed: data[:checks],
  93. latest_score: data[:scores].first
  94. }
  95. end
  96. end
  97. def self.brand_violations_breakdown(journey_id, days = 30)
  98. compliance_insights = where(journey_id: journey_id)
  99. .brand_compliance
  100. .where('calculated_at >= ?', days.days.ago)
  101. violation_categories = Hash.new(0)
  102. violation_severity = Hash.new(0)
  103. compliance_insights.each do |insight|
  104. violations = insight.data['violations'] || []
  105. violations.each do |violation|
  106. violation_categories[violation['type']] += 1
  107. violation_severity[violation['severity']] += 1
  108. end
  109. end
  110. {
  111. by_category: violation_categories,
  112. by_severity: violation_severity,
  113. total_violations: violation_categories.values.sum
  114. }
  115. end
  116. def self.calculate_score_trend(scores)
  117. return 'stable' if scores.length < 3
  118. recent_scores = scores.first(3)
  119. older_scores = scores.last(3)
  120. recent_avg = recent_scores.sum.to_f / recent_scores.length
  121. older_avg = older_scores.sum.to_f / older_scores.length
  122. diff = recent_avg - older_avg
  123. if diff > 0.05
  124. 'improving'
  125. elsif diff < -0.05
  126. 'declining'
  127. else
  128. 'stable'
  129. end
  130. end
  131. # Instance methods
  132. def expired?
  133. expires_at && expires_at <= Time.current
  134. end
  135. def active?
  136. !expired?
  137. end
  138. def age_in_hours
  139. ((Time.current - calculated_at) / 1.hour).round(2)
  140. end
  141. def age_in_days
  142. ((Time.current - calculated_at) / 1.day).round(2)
  143. end
  144. def time_to_expiry
  145. return nil unless expires_at
  146. seconds_remaining = expires_at - Time.current
  147. return 0 if seconds_remaining <= 0
  148. {
  149. days: (seconds_remaining / 1.day).floor,
  150. hours: ((seconds_remaining % 1.day) / 1.hour).floor,
  151. minutes: ((seconds_remaining % 1.hour) / 1.minute).floor
  152. }
  153. end
  154. # Insights data accessors
  155. def suggestions_data
  156. return {} unless insights_type == 'ai_suggestions'
  157. data['suggestions'] || []
  158. end
  159. def performance_data
  160. return {} unless insights_type == 'performance_metrics'
  161. data['metrics'] || {}
  162. end
  163. def user_behavior_data
  164. return {} unless insights_type == 'user_behavior'
  165. data['behavior_patterns'] || {}
  166. end
  167. def optimization_opportunities
  168. return [] unless insights_type == 'optimization_opportunities'
  169. data['opportunities'] || []
  170. end
  171. # Brand compliance data accessors
  172. def brand_compliance_data
  173. return {} unless insights_type == 'brand_compliance'
  174. {
  175. score: data['score'],
  176. compliant: data['compliant'],
  177. violations: data['violations'] || [],
  178. suggestions: data['suggestions'] || [],
  179. violations_count: data['violations_count'] || 0,
  180. step_id: data['step_id'],
  181. brand_id: data['brand_id']
  182. }
  183. end
  184. def brand_voice_data
  185. return {} unless insights_type == 'brand_voice_analysis'
  186. data['voice_analysis'] || {}
  187. end
  188. def brand_guideline_data
  189. return {} unless insights_type == 'brand_guideline_adherence'
  190. data['guideline_adherence'] || {}
  191. end
  192. # Data validation and integrity
  193. def validate_data_structure
  194. case insights_type
  195. when 'ai_suggestions'
  196. validate_suggestions_data
  197. when 'performance_metrics'
  198. validate_performance_data
  199. when 'user_behavior'
  200. validate_behavior_data
  201. when 'brand_compliance'
  202. validate_brand_compliance_data
  203. when 'brand_voice_analysis'
  204. validate_brand_voice_data
  205. when 'brand_guideline_adherence'
  206. validate_brand_guideline_data
  207. end
  208. end
  209. # Export and summary methods
  210. def to_summary
  211. {
  212. id: id,
  213. journey_id: journey_id,
  214. insights_type: insights_type,
  215. calculated_at: calculated_at,
  216. expires_at: expires_at,
  217. age_hours: age_in_hours,
  218. active: active?,
  219. data_keys: data.keys,
  220. metadata_keys: metadata.keys,
  221. provider: metadata['provider']
  222. }
  223. end
  224. def to_export
  225. {
  226. insights_type: insights_type,
  227. data: data,
  228. metadata: metadata,
  229. calculated_at: calculated_at,
  230. journey_context: {
  231. journey_id: journey_id,
  232. journey_name: journey.name,
  233. journey_status: journey.status
  234. }
  235. }
  236. end
  237. private
  238. def validate_suggestions_data
  239. suggestions = data['suggestions']
  240. return if suggestions.blank?
  241. unless suggestions.is_a?(Array)
  242. errors.add(:data, 'suggestions must be an array')
  243. return
  244. end
  245. suggestions.each_with_index do |suggestion, index|
  246. unless suggestion.is_a?(Hash)
  247. errors.add(:data, "suggestion at index #{index} must be a hash")
  248. next
  249. end
  250. required_keys = %w[name description stage content_type channel]
  251. missing_keys = required_keys - suggestion.keys
  252. if missing_keys.any?
  253. errors.add(:data, "suggestion at index #{index} missing keys: #{missing_keys.join(', ')}")
  254. end
  255. end
  256. end
  257. def validate_performance_data
  258. metrics = data['metrics']
  259. return if metrics.blank?
  260. unless metrics.is_a?(Hash)
  261. errors.add(:data, 'performance metrics must be a hash')
  262. end
  263. end
  264. def validate_behavior_data
  265. behavior = data['behavior_patterns']
  266. return if behavior.blank?
  267. unless behavior.is_a?(Hash)
  268. errors.add(:data, 'behavior patterns must be a hash')
  269. end
  270. end
  271. def validate_brand_compliance_data
  272. return if data.blank?
  273. required_keys = %w[score compliant violations_count]
  274. missing_keys = required_keys - data.keys
  275. if missing_keys.any?
  276. errors.add(:data, "brand compliance data missing keys: #{missing_keys.join(', ')}")
  277. end
  278. # Validate score is numeric and in valid range
  279. if data['score'].present? && (!data['score'].is_a?(Numeric) || data['score'] < 0 || data['score'] > 1)
  280. errors.add(:data, 'brand compliance score must be a number between 0 and 1')
  281. end
  282. # Validate compliant is boolean
  283. unless [true, false].include?(data['compliant'])
  284. errors.add(:data, 'brand compliance compliant field must be boolean')
  285. end
  286. # Validate violations array structure
  287. if data['violations'].present?
  288. unless data['violations'].is_a?(Array)
  289. errors.add(:data, 'violations must be an array')
  290. return
  291. end
  292. data['violations'].each_with_index do |violation, index|
  293. unless violation.is_a?(Hash)
  294. errors.add(:data, "violation at index #{index} must be a hash")
  295. next
  296. end
  297. violation_required_keys = %w[type severity message]
  298. violation_missing_keys = violation_required_keys - violation.keys
  299. if violation_missing_keys.any?
  300. errors.add(:data, "violation at index #{index} missing keys: #{violation_missing_keys.join(', ')}")
  301. end
  302. end
  303. end
  304. end
  305. def validate_brand_voice_data
  306. voice_data = data['voice_analysis']
  307. return if voice_data.blank?
  308. unless voice_data.is_a?(Hash)
  309. errors.add(:data, 'brand voice analysis must be a hash')
  310. end
  311. end
  312. def validate_brand_guideline_data
  313. guideline_data = data['guideline_adherence']
  314. return if guideline_data.blank?
  315. unless guideline_data.is_a?(Hash)
  316. errors.add(:data, 'brand guideline adherence must be a hash')
  317. end
  318. end
  319. validate :validate_data_structure
  320. # Callbacks
  321. before_save :set_default_expires_at, if: -> { expires_at.blank? && insights_type == 'ai_suggestions' }
  322. private
  323. def set_default_expires_at
  324. self.expires_at = 24.hours.from_now
  325. end
  326. end

app/models/journey_metric.rb

0.0% lines covered

283 relevant lines. 0 lines covered and 283 lines missed.
    
  1. class JourneyMetric < ApplicationRecord
  2. belongs_to :journey
  3. belongs_to :campaign
  4. belongs_to :user
  5. validates :metric_name, presence: true
  6. validates :metric_value, presence: true, numericality: true
  7. validates :metric_type, presence: true, inclusion: {
  8. in: %w[count rate percentage duration score index]
  9. }
  10. validates :aggregation_period, presence: true, inclusion: {
  11. in: %w[hourly daily weekly monthly quarterly yearly]
  12. }
  13. validates :calculated_at, presence: true
  14. # Ensure uniqueness of metrics per journey/period combination
  15. validates :metric_name, uniqueness: {
  16. scope: [:journey_id, :aggregation_period, :calculated_at]
  17. }
  18. scope :by_metric, ->(metric_name) { where(metric_name: metric_name) }
  19. scope :by_type, ->(metric_type) { where(metric_type: metric_type) }
  20. scope :by_period, ->(period) { where(aggregation_period: period) }
  21. scope :recent, -> { order(calculated_at: :desc) }
  22. scope :for_date_range, ->(start_date, end_date) { where(calculated_at: start_date..end_date) }
  23. # Common metric names
  24. CORE_METRICS = %w[
  25. total_executions completed_executions abandoned_executions
  26. conversion_rate completion_rate engagement_score
  27. average_completion_time bounce_rate click_through_rate
  28. cost_per_acquisition return_on_investment
  29. ].freeze
  30. ENGAGEMENT_METRICS = %w[
  31. page_views time_on_page scroll_depth interaction_rate
  32. social_shares comments likes video_completion_rate
  33. ].freeze
  34. CONVERSION_METRICS = %w[
  35. form_submissions downloads purchases signups
  36. trial_conversions subscription_rate upsell_rate
  37. ].freeze
  38. RETENTION_METRICS = %w[
  39. repeat_visits customer_lifetime_value churn_rate
  40. retention_rate loyalty_score net_promoter_score
  41. ].freeze
  42. ALL_METRICS = (CORE_METRICS + ENGAGEMENT_METRICS +
  43. CONVERSION_METRICS + RETENTION_METRICS).freeze
  44. def self.calculate_and_store_metrics(journey, period = 'daily')
  45. calculation_time = Time.current
  46. # Calculate core metrics
  47. calculate_core_metrics(journey, period, calculation_time)
  48. # Calculate engagement metrics
  49. calculate_engagement_metrics(journey, period, calculation_time)
  50. # Calculate conversion metrics
  51. calculate_conversion_metrics(journey, period, calculation_time)
  52. # Calculate retention metrics
  53. calculate_retention_metrics(journey, period, calculation_time)
  54. end
  55. def self.get_metric_trend(journey_id, metric_name, periods = 7, aggregation_period = 'daily')
  56. metrics = where(journey_id: journey_id, metric_name: metric_name, aggregation_period: aggregation_period)
  57. .order(calculated_at: :desc)
  58. .limit(periods)
  59. return [] if metrics.empty?
  60. values = metrics.reverse.pluck(:metric_value, :calculated_at)
  61. {
  62. metric_name: metric_name,
  63. values: values.map { |value, date| { value: value, date: date } },
  64. trend: calculate_trend_direction(values.map(&:first)),
  65. latest_value: values.last&.first,
  66. change_percentage: calculate_percentage_change(values.map(&:first))
  67. }
  68. end
  69. def self.get_journey_dashboard_metrics(journey_id, period = 'daily')
  70. latest_metrics = where(journey_id: journey_id, aggregation_period: period)
  71. .group(:metric_name)
  72. .maximum(:calculated_at)
  73. dashboard_data = {}
  74. latest_metrics.each do |metric_name, latest_date|
  75. metric = find_by(
  76. journey_id: journey_id,
  77. metric_name: metric_name,
  78. aggregation_period: period,
  79. calculated_at: latest_date
  80. )
  81. next unless metric
  82. dashboard_data[metric_name] = {
  83. value: metric.metric_value,
  84. type: metric.metric_type,
  85. calculated_at: metric.calculated_at,
  86. trend: get_metric_trend(journey_id, metric_name, 7, period)[:trend],
  87. metadata: metric.metadata
  88. }
  89. end
  90. dashboard_data
  91. end
  92. def self.compare_journey_metrics(journey1_id, journey2_id, metric_names = CORE_METRICS, period = 'daily')
  93. comparison = {}
  94. metric_names.each do |metric_name|
  95. journey1_metric = where(journey_id: journey1_id, metric_name: metric_name, aggregation_period: period)
  96. .order(calculated_at: :desc)
  97. .first
  98. journey2_metric = where(journey_id: journey2_id, metric_name: metric_name, aggregation_period: period)
  99. .order(calculated_at: :desc)
  100. .first
  101. next unless journey1_metric && journey2_metric
  102. comparison[metric_name] = {
  103. journey1_value: journey1_metric.metric_value,
  104. journey2_value: journey2_metric.metric_value,
  105. difference: journey2_metric.metric_value - journey1_metric.metric_value,
  106. percentage_change: calculate_percentage_change([journey1_metric.metric_value, journey2_metric.metric_value]),
  107. better_performer: journey1_metric.metric_value > journey2_metric.metric_value ? 'journey1' : 'journey2'
  108. }
  109. end
  110. comparison
  111. end
  112. def self.get_campaign_rollup_metrics(campaign_id, period = 'daily')
  113. campaign_journeys = Journey.where(campaign_id: campaign_id)
  114. return {} if campaign_journeys.empty?
  115. rollup_metrics = {}
  116. CORE_METRICS.each do |metric_name|
  117. journey_metrics = where(
  118. journey_id: campaign_journeys.pluck(:id),
  119. metric_name: metric_name,
  120. aggregation_period: period
  121. ).group(:journey_id)
  122. .maximum(:calculated_at)
  123. total_value = 0
  124. metric_count = 0
  125. journey_metrics.each do |journey_id, latest_date|
  126. metric = find_by(
  127. journey_id: journey_id,
  128. metric_name: metric_name,
  129. aggregation_period: period,
  130. calculated_at: latest_date
  131. )
  132. if metric
  133. if %w[count duration].include?(metric.metric_type)
  134. total_value += metric.metric_value
  135. else
  136. total_value += metric.metric_value
  137. end
  138. metric_count += 1
  139. end
  140. end
  141. next if metric_count == 0
  142. rollup_metrics[metric_name] = if %w[rate percentage score].include?(get_metric_type(metric_name))
  143. total_value / metric_count # Average for rates/percentages
  144. else
  145. total_value # Sum for counts
  146. end
  147. end
  148. rollup_metrics
  149. end
  150. def formatted_value
  151. case metric_type
  152. when 'percentage', 'rate'
  153. "#{metric_value.round(1)}%"
  154. when 'duration'
  155. format_duration(metric_value)
  156. when 'count'
  157. metric_value.to_i.to_s
  158. else
  159. metric_value.round(2).to_s
  160. end
  161. end
  162. def self.metric_definition(metric_name)
  163. definitions = {
  164. 'total_executions' => 'Total number of journey executions started',
  165. 'completed_executions' => 'Number of journeys completed successfully',
  166. 'abandoned_executions' => 'Number of journeys abandoned before completion',
  167. 'conversion_rate' => 'Percentage of executions that resulted in conversion',
  168. 'completion_rate' => 'Percentage of executions that were completed',
  169. 'engagement_score' => 'Overall engagement score based on interactions',
  170. 'average_completion_time' => 'Average time to complete the journey',
  171. 'bounce_rate' => 'Percentage of visitors who left after viewing only one step',
  172. 'click_through_rate' => 'Percentage of users who clicked through to next step'
  173. }
  174. definitions[metric_name] || 'Custom metric'
  175. end
  176. private
  177. def self.calculate_core_metrics(journey, period, calculation_time)
  178. period_start = get_period_start(calculation_time, period)
  179. executions = journey.journey_executions.where(created_at: period_start..calculation_time)
  180. # Total executions
  181. create_metric(journey, 'total_executions', executions.count, 'count', period, calculation_time)
  182. # Completed executions
  183. completed = executions.where(status: 'completed').count
  184. create_metric(journey, 'completed_executions', completed, 'count', period, calculation_time)
  185. # Abandoned executions
  186. abandoned = executions.where(status: 'abandoned').count
  187. create_metric(journey, 'abandoned_executions', abandoned, 'count', period, calculation_time)
  188. # Completion rate
  189. completion_rate = executions.count > 0 ? (completed.to_f / executions.count * 100) : 0
  190. create_metric(journey, 'completion_rate', completion_rate, 'percentage', period, calculation_time)
  191. # Average completion time
  192. completed_executions = executions.where(status: 'completed').where.not(completed_at: nil)
  193. avg_time = if completed_executions.any?
  194. completed_executions.average('completed_at - started_at') || 0
  195. else
  196. 0
  197. end
  198. create_metric(journey, 'average_completion_time', avg_time, 'duration', period, calculation_time)
  199. end
  200. def self.calculate_engagement_metrics(journey, period, calculation_time)
  201. # Placeholder for engagement metrics calculation
  202. # This would integrate with actual user interaction data
  203. # For now, create sample metrics
  204. create_metric(journey, 'engagement_score', rand(70..95), 'score', period, calculation_time)
  205. create_metric(journey, 'interaction_rate', rand(40..80), 'percentage', period, calculation_time)
  206. end
  207. def self.calculate_conversion_metrics(journey, period, calculation_time)
  208. # Placeholder for conversion metrics calculation
  209. # This would integrate with actual conversion tracking
  210. period_start = get_period_start(calculation_time, period)
  211. executions = journey.journey_executions.where(created_at: period_start..calculation_time)
  212. # Simple conversion rate based on completed journeys
  213. conversion_rate = if executions.count > 0
  214. (executions.where(status: 'completed').count.to_f / executions.count * 100)
  215. else
  216. 0
  217. end
  218. create_metric(journey, 'conversion_rate', conversion_rate, 'percentage', period, calculation_time)
  219. end
  220. def self.calculate_retention_metrics(journey, period, calculation_time)
  221. # Placeholder for retention metrics calculation
  222. # This would integrate with actual user behavior tracking
  223. create_metric(journey, 'retention_rate', rand(60..85), 'percentage', period, calculation_time)
  224. end
  225. def self.create_metric(journey, metric_name, value, type, period, calculation_time)
  226. create!(
  227. journey: journey,
  228. campaign: journey.campaign,
  229. user: journey.user,
  230. metric_name: metric_name,
  231. metric_value: value,
  232. metric_type: type,
  233. aggregation_period: period,
  234. calculated_at: calculation_time
  235. )
  236. rescue ActiveRecord::RecordNotUnique
  237. # Metric already exists for this period, update it
  238. existing = find_by(
  239. journey: journey,
  240. metric_name: metric_name,
  241. aggregation_period: period,
  242. calculated_at: calculation_time
  243. )
  244. existing&.update!(metric_value: value)
  245. end
  246. def self.get_period_start(calculation_time, period)
  247. case period
  248. when 'hourly' then calculation_time.beginning_of_hour
  249. when 'daily' then calculation_time.beginning_of_day
  250. when 'weekly' then calculation_time.beginning_of_week
  251. when 'monthly' then calculation_time.beginning_of_month
  252. when 'quarterly' then calculation_time.beginning_of_quarter
  253. when 'yearly' then calculation_time.beginning_of_year
  254. else calculation_time.beginning_of_day
  255. end
  256. end
  257. def self.calculate_trend_direction(values)
  258. return :stable if values.length < 2
  259. first_half = values[0...(values.length / 2)]
  260. second_half = values[(values.length / 2)..-1]
  261. first_avg = first_half.sum.to_f / first_half.length
  262. second_avg = second_half.sum.to_f / second_half.length
  263. change_percentage = ((second_avg - first_avg) / first_avg * 100) rescue 0
  264. if change_percentage > 5
  265. :up
  266. elsif change_percentage < -5
  267. :down
  268. else
  269. :stable
  270. end
  271. end
  272. def self.calculate_percentage_change(values)
  273. return 0 if values.length < 2 || values.first == 0
  274. ((values.last - values.first) / values.first * 100).round(1)
  275. end
  276. def self.get_metric_type(metric_name)
  277. case metric_name
  278. when *%w[total_executions completed_executions abandoned_executions]
  279. 'count'
  280. when *%w[conversion_rate completion_rate bounce_rate]
  281. 'percentage'
  282. when 'average_completion_time'
  283. 'duration'
  284. when 'engagement_score'
  285. 'score'
  286. else
  287. 'rate'
  288. end
  289. end
  290. def format_duration(seconds)
  291. return '0s' if seconds == 0
  292. if seconds >= 1.hour
  293. hours = (seconds / 1.hour).to_i
  294. minutes = ((seconds % 1.hour) / 1.minute).to_i
  295. "#{hours}h #{minutes}m"
  296. elsif seconds >= 1.minute
  297. minutes = (seconds / 1.minute).to_i
  298. "#{minutes}m"
  299. else
  300. "#{seconds.to_i}s"
  301. end
  302. end
  303. end

app/models/journey_step.rb

0.0% lines covered

363 relevant lines. 0 lines covered and 363 lines missed.
    
  1. class JourneyStep < ApplicationRecord
  2. belongs_to :journey
  3. has_many :transitions_from, class_name: 'StepTransition', foreign_key: 'from_step_id', dependent: :destroy
  4. has_many :transitions_to, class_name: 'StepTransition', foreign_key: 'to_step_id', dependent: :destroy
  5. has_many :next_steps, through: :transitions_from, source: :to_step
  6. has_many :previous_steps, through: :transitions_to, source: :from_step
  7. STEP_TYPES = %w[
  8. blog_post
  9. email_sequence
  10. social_media
  11. lead_magnet
  12. webinar
  13. case_study
  14. sales_call
  15. demo
  16. trial_offer
  17. onboarding
  18. newsletter
  19. feedback_survey
  20. ].freeze
  21. CONTENT_TYPES = %w[
  22. email
  23. blog_post
  24. social_post
  25. landing_page
  26. video
  27. webinar
  28. ebook
  29. case_study
  30. whitepaper
  31. infographic
  32. podcast
  33. advertisement
  34. survey
  35. demo
  36. consultation
  37. ].freeze
  38. CHANNELS = %w[
  39. email
  40. website
  41. facebook
  42. instagram
  43. twitter
  44. linkedin
  45. youtube
  46. google_ads
  47. display_ads
  48. sms
  49. push_notification
  50. direct_mail
  51. event
  52. sales_call
  53. ].freeze
  54. validates :name, presence: true
  55. validates :stage, inclusion: { in: Journey::STAGES }
  56. validates :position, presence: true, numericality: { greater_than_or_equal_to: 0 }
  57. validates :content_type, inclusion: { in: CONTENT_TYPES }, allow_blank: true
  58. validates :channel, inclusion: { in: CHANNELS }, allow_blank: true
  59. validates :duration_days, numericality: { greater_than: 0 }, allow_blank: true
  60. # Brand compliance validations
  61. validate :validate_brand_compliance, if: :should_validate_brand_compliance?
  62. scope :by_position, -> { order(:position) }
  63. scope :by_stage, ->(stage) { where(stage: stage) }
  64. scope :entry_points, -> { where(is_entry_point: true) }
  65. scope :exit_points, -> { where(is_exit_point: true) }
  66. before_create :set_position
  67. after_destroy :reorder_positions
  68. # Brand compliance callbacks
  69. before_save :check_real_time_compliance, if: :should_check_compliance?
  70. after_update :broadcast_compliance_status, if: :saved_change_to_description?
  71. def move_to_position(new_position)
  72. return if new_position == position
  73. transaction do
  74. if new_position < position
  75. journey.journey_steps
  76. .where(position: new_position...position)
  77. .update_all('position = position + 1')
  78. else
  79. journey.journey_steps
  80. .where(position: (position + 1)..new_position)
  81. .update_all('position = position - 1')
  82. end
  83. update!(position: new_position)
  84. end
  85. end
  86. def add_transition_to(to_step, conditions = {})
  87. transition_type = conditions.present? ? 'conditional' : 'sequential'
  88. transitions_from.create!(
  89. to_step: to_step,
  90. conditions: conditions,
  91. transition_type: transition_type
  92. )
  93. end
  94. def remove_transition_to(to_step)
  95. transitions_from.where(to_step: to_step).destroy_all
  96. end
  97. def can_transition_to?(step)
  98. next_steps.include?(step)
  99. end
  100. def evaluate_conditions(context = {})
  101. return true if conditions.blank?
  102. conditions.all? do |key, value|
  103. case key
  104. when 'min_engagement_score'
  105. context['engagement_score'].to_i >= value.to_i
  106. when 'completed_action'
  107. context['completed_actions']&.include?(value)
  108. when 'time_since_last_action'
  109. context['time_since_last_action'].to_i >= value.to_i
  110. else
  111. true
  112. end
  113. end
  114. end
  115. def to_json_export
  116. {
  117. name: name,
  118. description: description,
  119. stage: stage,
  120. position: position,
  121. content_type: content_type,
  122. channel: channel,
  123. duration_days: duration_days,
  124. config: config,
  125. conditions: conditions,
  126. metadata: metadata,
  127. is_entry_point: is_entry_point,
  128. is_exit_point: is_exit_point,
  129. transitions: transitions_from.map { |t| { to: t.to_step.name, conditions: t.conditions } }
  130. }
  131. end
  132. # Brand compliance methods
  133. def check_brand_compliance(options = {})
  134. return no_brand_result unless has_brand?
  135. compliance_service = Journey::BrandComplianceService.new(
  136. journey: journey,
  137. step: self,
  138. content: compilable_content,
  139. context: build_compliance_context
  140. )
  141. compliance_service.check_compliance(options)
  142. end
  143. def brand_compliant?(threshold = nil)
  144. return true unless has_brand?
  145. compliance_service = Journey::BrandComplianceService.new(
  146. journey: journey,
  147. step: self,
  148. content: compilable_content,
  149. context: build_compliance_context
  150. )
  151. compliance_service.meets_minimum_compliance?(threshold)
  152. end
  153. def quick_compliance_score
  154. return 1.0 unless has_brand?
  155. compliance_service = Journey::BrandComplianceService.new(
  156. journey: journey,
  157. step: self,
  158. content: compilable_content,
  159. context: build_compliance_context
  160. )
  161. compliance_service.quick_score
  162. end
  163. def compliance_violations
  164. return [] unless has_brand?
  165. result = check_brand_compliance
  166. result[:violations] || []
  167. end
  168. def compliance_suggestions
  169. return [] unless has_brand?
  170. compliance_service = Journey::BrandComplianceService.new(
  171. journey: journey,
  172. step: self,
  173. content: compilable_content,
  174. context: build_compliance_context
  175. )
  176. recommendations = compliance_service.get_recommendations
  177. recommendations[:recommendations] || []
  178. end
  179. def auto_fix_compliance_issues
  180. return { fixed: false, content: compilable_content } unless has_brand?
  181. compliance_service = Journey::BrandComplianceService.new(
  182. journey: journey,
  183. step: self,
  184. content: compilable_content,
  185. context: build_compliance_context
  186. )
  187. fix_results = compliance_service.auto_fix_violations
  188. if fix_results[:fixed_content].present?
  189. # Update description with fixed content if auto-fix was successful
  190. update_column(:description, fix_results[:fixed_content])
  191. { fixed: true, content: fix_results[:fixed_content], fixes: fix_results[:fixes_applied] }
  192. else
  193. { fixed: false, content: compilable_content, available_fixes: fix_results[:fixes_available] }
  194. end
  195. end
  196. def messaging_compliant?(message_text = nil)
  197. return true unless has_brand?
  198. content_to_check = message_text || compilable_content
  199. compliance_service = Journey::BrandComplianceService.new(
  200. journey: journey,
  201. step: self,
  202. content: content_to_check,
  203. context: build_compliance_context
  204. )
  205. compliance_service.messaging_allowed?(content_to_check)
  206. end
  207. def applicable_brand_guidelines
  208. return [] unless has_brand?
  209. compliance_service = Journey::BrandComplianceService.new(
  210. journey: journey,
  211. step: self,
  212. content: compilable_content,
  213. context: build_compliance_context
  214. )
  215. compliance_service.applicable_brand_rules
  216. end
  217. def brand_context
  218. return {} unless has_brand?
  219. {
  220. brand_id: journey.brand.id,
  221. brand_name: journey.brand.name,
  222. industry: journey.brand.industry,
  223. has_messaging_framework: journey.brand.messaging_framework.present?,
  224. has_guidelines: journey.brand.brand_guidelines.active.any?,
  225. compliance_level: determine_compliance_level
  226. }
  227. end
  228. def latest_compliance_check
  229. journey.journey_insights
  230. .where(insights_type: 'brand_compliance')
  231. .where("data->>'step_id' = ?", id.to_s)
  232. .order(calculated_at: :desc)
  233. .first
  234. end
  235. def compliance_history(days = 30)
  236. journey.journey_insights
  237. .where(insights_type: 'brand_compliance')
  238. .where("data->>'step_id' = ?", id.to_s)
  239. .where('calculated_at >= ?', days.days.ago)
  240. .order(calculated_at: :desc)
  241. end
  242. private
  243. def set_position
  244. if position.nil? || position == 0
  245. max_position = journey.journey_steps.where.not(id: id).maximum(:position) || -1
  246. self.position = max_position + 1
  247. end
  248. end
  249. def reorder_positions
  250. journey.journey_steps.where('position > ?', position).update_all('position = position - 1')
  251. end
  252. # Brand compliance private methods
  253. def should_validate_brand_compliance?
  254. has_brand? &&
  255. (description_changed? || name_changed?) &&
  256. !skip_brand_validation? &&
  257. compilable_content.present?
  258. end
  259. def should_check_compliance?
  260. has_brand? &&
  261. (will_save_change_to_description? || will_save_change_to_name?) &&
  262. !skip_compliance_check?
  263. end
  264. def validate_brand_compliance
  265. return unless compilable_content.present?
  266. compliance_service = Journey::BrandComplianceService.new(
  267. journey: journey,
  268. step: self,
  269. content: compilable_content,
  270. context: build_compliance_context
  271. )
  272. # Quick validation check
  273. result = compliance_service.pre_generation_check(compilable_content)
  274. unless result[:allowed]
  275. violations = result[:violations] || []
  276. if violations.any?
  277. critical_violations = violations.select { |v| v[:severity] == 'critical' }
  278. if critical_violations.any?
  279. errors.add(:description, "Content violates critical brand guidelines: #{critical_violations.map { |v| v[:message] }.join(', ')}")
  280. else
  281. # Add warnings for non-critical violations
  282. errors.add(:description, "Content may violate brand guidelines: #{violations.first[:message]}") if violations.any?
  283. end
  284. end
  285. end
  286. end
  287. def check_real_time_compliance
  288. return unless compilable_content.present?
  289. # Store compliance check in metadata for later reference
  290. compliance_score = quick_compliance_score
  291. self.metadata ||= {}
  292. self.metadata['last_compliance_check'] = {
  293. score: compliance_score,
  294. checked_at: Time.current.iso8601,
  295. compliant: compliance_score >= 0.7
  296. }
  297. # Log warning for low compliance scores
  298. if compliance_score < 0.5
  299. Rails.logger.warn "Journey step #{id} has low brand compliance score: #{compliance_score}"
  300. end
  301. end
  302. def broadcast_compliance_status
  303. return unless has_brand?
  304. # Broadcast real-time compliance status update
  305. ActionCable.server.broadcast(
  306. "journey_step_compliance_#{id}",
  307. {
  308. event: 'compliance_updated',
  309. step_id: id,
  310. journey_id: journey.id,
  311. brand_id: journey.brand.id,
  312. compliance_score: quick_compliance_score,
  313. timestamp: Time.current
  314. }
  315. )
  316. rescue => e
  317. Rails.logger.error "Failed to broadcast compliance status: #{e.message}"
  318. end
  319. def has_brand?
  320. journey&.brand_id.present?
  321. end
  322. def compilable_content
  323. # Combine name and description for compliance checking
  324. content_parts = [name, description].compact
  325. content_parts.join(". ").strip
  326. end
  327. def build_compliance_context
  328. {
  329. step_id: id,
  330. step_name: name,
  331. content_type: content_type,
  332. channel: channel,
  333. stage: stage,
  334. position: position,
  335. is_entry_point: is_entry_point,
  336. is_exit_point: is_exit_point,
  337. journey_context: {
  338. campaign_type: journey.campaign_type,
  339. target_audience: journey.target_audience,
  340. goals: journey.goals
  341. }
  342. }
  343. end
  344. def determine_compliance_level
  345. # Determine compliance level based on step characteristics
  346. if is_entry_point? || stage == 'awareness'
  347. :strict # Entry points need strict brand compliance
  348. elsif %w[conversion retention].include?(stage)
  349. :standard # Important stages need standard compliance
  350. else
  351. :flexible # Other stages can be more flexible
  352. end
  353. end
  354. def skip_brand_validation?
  355. # Allow skipping validation in certain contexts
  356. metadata&.dig('skip_brand_validation') == true ||
  357. Rails.env.test? && metadata&.dig('test_skip_validation') == true
  358. end
  359. def skip_compliance_check?
  360. # Allow skipping real-time compliance checks
  361. metadata&.dig('skip_compliance_check') == true ||
  362. Rails.env.test? && metadata&.dig('test_skip_compliance') == true
  363. end
  364. def no_brand_result
  365. {
  366. compliant: true,
  367. score: 1.0,
  368. summary: "No brand associated with journey",
  369. violations: [],
  370. suggestions: [],
  371. step_context: {
  372. step_id: id,
  373. no_brand: true
  374. }
  375. }
  376. end
  377. end

app/models/journey_template.rb

0.0% lines covered

186 relevant lines. 0 lines covered and 186 lines missed.
    
  1. class JourneyTemplate < ApplicationRecord
  2. has_many :journeys
  3. # Versioning associations
  4. belongs_to :original_template, class_name: 'JourneyTemplate', optional: true
  5. has_many :versions, class_name: 'JourneyTemplate', foreign_key: 'original_template_id', dependent: :destroy
  6. CATEGORIES = %w[
  7. b2b
  8. b2c
  9. ecommerce
  10. saas
  11. nonprofit
  12. education
  13. healthcare
  14. financial_services
  15. real_estate
  16. hospitality
  17. ].freeze
  18. DIFFICULTY_LEVELS = %w[beginner intermediate advanced].freeze
  19. validates :name, presence: true
  20. validates :category, presence: true, inclusion: { in: CATEGORIES }
  21. validates :campaign_type, inclusion: { in: Journey::CAMPAIGN_TYPES }, allow_blank: true
  22. validates :difficulty_level, inclusion: { in: DIFFICULTY_LEVELS }, allow_blank: true
  23. validates :estimated_duration_days, numericality: { greater_than: 0 }, allow_blank: true
  24. validates :version, presence: true, numericality: { greater_than: 0 }
  25. validates :version, uniqueness: { scope: :original_template_id }, if: :original_template_id?
  26. scope :active, -> { where(is_active: true) }
  27. scope :by_category, ->(category) { where(category: category) }
  28. scope :by_campaign_type, ->(type) { where(campaign_type: type) }
  29. scope :popular, -> { order(usage_count: :desc) }
  30. scope :recent, -> { order(created_at: :desc) }
  31. scope :published_versions, -> { where(is_published_version: true) }
  32. scope :latest_versions, -> { joins("LEFT JOIN journey_templates jt2 ON jt2.original_template_id = journey_templates.original_template_id AND jt2.version > journey_templates.version").where("jt2.id IS NULL") }
  33. def create_journey_for_user(user, journey_params = {})
  34. journey = user.journeys.build(
  35. name: journey_params[:name] || "#{name} - #{Date.current}",
  36. description: journey_params[:description] || description,
  37. campaign_type: campaign_type,
  38. target_audience: journey_params[:target_audience],
  39. goals: journey_params[:goals],
  40. brand_id: journey_params[:brand_id],
  41. metadata: {
  42. template_id: id,
  43. template_name: name,
  44. created_from_template: true
  45. }
  46. )
  47. if journey.save
  48. create_steps_for_journey(journey)
  49. increment!(:usage_count)
  50. journey
  51. else
  52. journey
  53. end
  54. end
  55. def preview_steps
  56. template_data['steps'] || []
  57. end
  58. def steps_data
  59. template_data['steps'] || []
  60. end
  61. def steps_data=(value)
  62. self.template_data = (template_data || {}).merge('steps' => value)
  63. end
  64. def connections_data
  65. template_data['connections'] || []
  66. end
  67. def connections_data=(value)
  68. self.template_data = (template_data || {}).merge('connections' => value)
  69. end
  70. def step_count
  71. preview_steps.size
  72. end
  73. def stages_covered
  74. preview_steps.map { |step| step['stage'] }.uniq
  75. end
  76. def channels_used
  77. preview_steps.map { |step| step['channel'] }.uniq.compact
  78. end
  79. def content_types_included
  80. preview_steps.map { |step| step['content_type'] }.uniq.compact
  81. end
  82. def is_original?
  83. original_template_id.nil?
  84. end
  85. def root_template
  86. original_template || self
  87. end
  88. def all_versions
  89. if is_original?
  90. [self] + versions.order(:version)
  91. else
  92. original_template.versions.order(:version)
  93. end
  94. end
  95. def latest_version
  96. if is_original?
  97. versions.order(:version).last || self
  98. else
  99. original_template.latest_version
  100. end
  101. end
  102. def create_new_version(version_params = {})
  103. new_version_number = calculate_next_version_number
  104. new_version = self.dup
  105. new_version.assign_attributes(
  106. original_template: root_template,
  107. version: new_version_number,
  108. parent_version: version,
  109. version_notes: version_params[:version_notes],
  110. is_published_version: version_params[:is_published_version] || false,
  111. usage_count: 0,
  112. is_active: true
  113. )
  114. # Update name to include version if it's not the original
  115. unless new_version.name.match(/v\d+\.\d+/)
  116. new_version.name = "#{name} v#{new_version_number}"
  117. end
  118. new_version
  119. end
  120. def publish_version!
  121. transaction do
  122. # Unpublish other versions of the same template
  123. root_template.versions.update_all(is_published_version: false)
  124. if root_template != self
  125. root_template.update!(is_published_version: false)
  126. end
  127. # Publish this version
  128. update!(is_published_version: true)
  129. end
  130. end
  131. def version_history
  132. all_versions.map do |version|
  133. {
  134. version: version.version,
  135. created_at: version.created_at,
  136. version_notes: version.version_notes,
  137. is_published: version.is_published_version,
  138. usage_count: version.usage_count
  139. }
  140. end
  141. end
  142. private
  143. def calculate_next_version_number
  144. existing_versions = root_template.versions.pluck(:version)
  145. existing_versions << root_template.version
  146. major_version = existing_versions.map(&:to_i).max || 1
  147. minor_versions = existing_versions.select { |v| v.to_i == major_version }.map { |v| (v % 1 * 100).to_i }
  148. next_minor = (minor_versions.max || 0) + 1
  149. # If minor version reaches 100, increment major version
  150. if next_minor >= 100
  151. major_version += 1
  152. next_minor = 0
  153. end
  154. major_version + (next_minor / 100.0)
  155. end
  156. def create_steps_for_journey(journey)
  157. return unless template_data['steps'].present?
  158. step_mapping = {}
  159. # First pass: create all steps
  160. template_data['steps'].each_with_index do |step_data, index|
  161. step = journey.journey_steps.create!(
  162. name: step_data['name'],
  163. description: step_data['description'],
  164. stage: step_data['stage'],
  165. position: index,
  166. content_type: step_data['content_type'],
  167. channel: step_data['channel'],
  168. duration_days: step_data['duration_days'] || 1,
  169. config: step_data['config'] || {},
  170. conditions: step_data['conditions'] || {},
  171. metadata: step_data['metadata'] || {},
  172. is_entry_point: step_data['is_entry_point'] || (index == 0),
  173. is_exit_point: step_data['is_exit_point'] || false
  174. )
  175. step_mapping[step_data['id']] = step if step_data['id']
  176. end
  177. # Second pass: create transitions
  178. template_data['transitions']&.each do |transition_data|
  179. from_step = step_mapping[transition_data['from_step_id']]
  180. to_step = step_mapping[transition_data['to_step_id']]
  181. if from_step && to_step
  182. StepTransition.create!(
  183. from_step: from_step,
  184. to_step: to_step,
  185. transition_type: transition_data['transition_type'] || 'sequential',
  186. conditions: transition_data['conditions'] || {},
  187. priority: transition_data['priority'] || 0,
  188. metadata: transition_data['metadata'] || {}
  189. )
  190. end
  191. end
  192. end
  193. end

app/models/messaging_framework.rb

0.0% lines covered

64 relevant lines. 0 lines covered and 64 lines missed.
    
  1. class MessagingFramework < ApplicationRecord
  2. belongs_to :brand
  3. # Validations
  4. validates :brand, presence: true, uniqueness: { scope: :active, if: :active? }
  5. # Scopes
  6. scope :active, -> { where(active: true) }
  7. # Callbacks
  8. before_save :ensure_arrays_for_lists
  9. # Methods
  10. def add_key_message(category, message)
  11. self.key_messages ||= {}
  12. self.key_messages[category] ||= []
  13. self.key_messages[category] << message unless self.key_messages[category].include?(message)
  14. save
  15. end
  16. def add_value_proposition(proposition)
  17. self.value_propositions ||= {}
  18. self.value_propositions["main"] ||= []
  19. self.value_propositions["main"] << proposition unless self.value_propositions["main"].include?(proposition)
  20. save
  21. end
  22. def add_approved_phrase(phrase)
  23. self.approved_phrases ||= []
  24. self.approved_phrases << phrase unless self.approved_phrases.include?(phrase)
  25. save
  26. end
  27. def add_banned_word(word)
  28. self.banned_words ||= []
  29. self.banned_words << word.downcase unless self.banned_words.include?(word.downcase)
  30. save
  31. end
  32. def remove_banned_word(word)
  33. self.banned_words ||= []
  34. self.banned_words.delete(word.downcase)
  35. save
  36. end
  37. def is_word_banned?(word)
  38. return false if banned_words.blank?
  39. banned_words.include?(word.downcase)
  40. end
  41. def contains_banned_words?(text)
  42. return false if banned_words.blank?
  43. words = text.downcase.split(/\W+/)
  44. (words & banned_words).any?
  45. end
  46. def get_banned_words_in_text(text)
  47. return [] if banned_words.blank?
  48. words = text.downcase.split(/\W+/)
  49. words & banned_words
  50. end
  51. def tone_formal?
  52. tone_attributes["formality"] == "formal"
  53. end
  54. def tone_casual?
  55. tone_attributes["formality"] == "casual"
  56. end
  57. def tone_professional?
  58. tone_attributes["style"] == "professional"
  59. end
  60. def tone_friendly?
  61. tone_attributes["style"] == "friendly"
  62. end
  63. private
  64. def ensure_arrays_for_lists
  65. self.approved_phrases = [] if approved_phrases.nil?
  66. self.banned_words = [] if banned_words.nil?
  67. end
  68. end

app/models/persona.rb

51.35% lines covered

37 relevant lines. 19 lines covered and 18 lines missed.
    
  1. 1 class Persona < ApplicationRecord
  2. 1 belongs_to :user
  3. 1 has_many :campaigns, dependent: :destroy
  4. 1 has_many :journeys, through: :campaigns
  5. 1 validates :name, presence: true, uniqueness: { scope: :user_id }
  6. 1 validates :description, presence: true
  7. # Demographic fields
  8. DEMOGRAPHIC_FIELDS = %w[
  9. 1 age_range gender location income_level education_level
  10. employment_status family_status occupation
  11. ].freeze
  12. # Behavior fields
  13. BEHAVIOR_FIELDS = %w[
  14. 1 online_activity purchase_behavior social_media_usage
  15. content_preferences communication_preferences device_usage
  16. ].freeze
  17. # Preference fields
  18. PREFERENCE_FIELDS = %w[
  19. 1 brand_loyalty price_sensitivity channel_preferences
  20. messaging_tone content_types shopping_habits
  21. ].freeze
  22. # Psychographic fields
  23. PSYCHOGRAPHIC_FIELDS = %w[
  24. 1 values personality_traits lifestyle interests
  25. attitudes motivations goals pain_points
  26. ].freeze
  27. 1 scope :active, -> { joins(:campaigns).where(campaigns: { status: ['active', 'published'] }).distinct }
  28. 1 def display_name
  29. name
  30. end
  31. 1 def age_range
  32. demographics['age_range']
  33. end
  34. 1 def primary_channel
  35. preferences['channel_preferences']&.first
  36. end
  37. 1 def total_campaigns
  38. campaigns.count
  39. end
  40. 1 def active_campaigns
  41. campaigns.where(status: ['active', 'published']).count
  42. end
  43. 1 def demographics_summary
  44. return 'No demographics data' if demographics.blank?
  45. summary = []
  46. summary << "Age: #{demographics['age_range']}" if demographics['age_range'].present?
  47. summary << "Location: #{demographics['location']}" if demographics['location'].present?
  48. summary << "Income: #{demographics['income_level']}" if demographics['income_level'].present?
  49. summary.any? ? summary.join(', ') : 'Limited demographics data'
  50. end
  51. 1 def behavior_summary
  52. return 'No behavior data' if behaviors.blank?
  53. summary = []
  54. summary << "Online: #{behaviors['online_activity']}" if behaviors['online_activity'].present?
  55. summary << "Purchase: #{behaviors['purchase_behavior']}" if behaviors['purchase_behavior'].present?
  56. summary << "Social: #{behaviors['social_media_usage']}" if behaviors['social_media_usage'].present?
  57. summary.any? ? summary.join(', ') : 'Limited behavior data'
  58. end
  59. 1 def to_campaign_context
  60. {
  61. name: name,
  62. description: description,
  63. demographics: demographics_summary,
  64. behaviors: behavior_summary,
  65. preferences: preferences['messaging_tone'] || 'neutral',
  66. channels: preferences['channel_preferences'] || []
  67. }
  68. end
  69. end

app/models/session.rb

0.0% lines covered

25 relevant lines. 0 lines covered and 25 lines missed.
    
  1. class Session < ApplicationRecord
  2. belongs_to :user
  3. # Constants
  4. SESSION_TIMEOUT = 24.hours
  5. INACTIVE_TIMEOUT = 2.hours
  6. # Scopes
  7. scope :active, -> { where("expires_at > ?", Time.current) }
  8. scope :expired, -> { where("expires_at <= ?", Time.current) }
  9. # Callbacks
  10. before_create :set_expiration
  11. # Instance methods
  12. def expired?
  13. expires_at <= Time.current
  14. end
  15. def inactive?
  16. last_active_at && last_active_at < INACTIVE_TIMEOUT.ago
  17. end
  18. def touch_activity!
  19. update!(last_active_at: Time.current)
  20. end
  21. def extend_session!
  22. update!(expires_at: SESSION_TIMEOUT.from_now)
  23. end
  24. private
  25. def set_expiration
  26. self.expires_at ||= SESSION_TIMEOUT.from_now
  27. self.last_active_at ||= Time.current
  28. end
  29. end

app/models/step_execution.rb

0.0% lines covered

64 relevant lines. 0 lines covered and 64 lines missed.
    
  1. class StepExecution < ApplicationRecord
  2. belongs_to :journey_execution
  3. belongs_to :journey_step
  4. STATUSES = %w[pending in_progress completed failed skipped].freeze
  5. validates :status, inclusion: { in: STATUSES }
  6. scope :completed, -> { where(status: 'completed') }
  7. scope :failed, -> { where(status: 'failed') }
  8. scope :pending, -> { where(status: 'pending') }
  9. scope :in_progress, -> { where(status: 'in_progress') }
  10. def start!
  11. update!(status: 'in_progress', started_at: Time.current)
  12. end
  13. def complete!(result = {})
  14. update!(
  15. status: 'completed',
  16. completed_at: Time.current,
  17. result_data: result_data.merge(result)
  18. )
  19. end
  20. def fail!(reason = nil)
  21. data = result_data.dup
  22. data['failure_reason'] = reason if reason
  23. data['failed_at'] = Time.current
  24. update!(
  25. status: 'failed',
  26. completed_at: Time.current,
  27. result_data: data
  28. )
  29. end
  30. def skip!(reason = nil)
  31. data = result_data.dup
  32. data['skip_reason'] = reason if reason
  33. data['skipped_at'] = Time.current
  34. update!(
  35. status: 'skipped',
  36. completed_at: Time.current,
  37. result_data: data
  38. )
  39. end
  40. def duration
  41. return 0 unless started_at && completed_at
  42. completed_at - started_at
  43. end
  44. def add_result(key, value)
  45. data = result_data.dup
  46. data[key.to_s] = value
  47. update!(result_data: data)
  48. end
  49. def get_result(key)
  50. result_data[key.to_s]
  51. end
  52. def success?
  53. status == 'completed'
  54. end
  55. def failed?
  56. status == 'failed'
  57. end
  58. def pending?
  59. status == 'pending'
  60. end
  61. def in_progress?
  62. status == 'in_progress'
  63. end
  64. end

app/models/step_transition.rb

0.0% lines covered

54 relevant lines. 0 lines covered and 54 lines missed.
    
  1. class StepTransition < ApplicationRecord
  2. belongs_to :from_step, class_name: 'JourneyStep'
  3. belongs_to :to_step, class_name: 'JourneyStep'
  4. TRANSITION_TYPES = %w[sequential conditional split merge].freeze
  5. validates :from_step, presence: true
  6. validates :to_step, presence: true
  7. validates :transition_type, inclusion: { in: TRANSITION_TYPES }
  8. validates :priority, numericality: { greater_than_or_equal_to: 0 }
  9. validate :prevent_self_reference
  10. validate :steps_in_same_journey
  11. scope :by_priority, -> { order(:priority) }
  12. scope :conditional, -> { where(transition_type: 'conditional') }
  13. scope :sequential, -> { where(transition_type: 'sequential') }
  14. def evaluate(context = {})
  15. return true if conditions.blank?
  16. conditions.all? do |condition_type, condition_value|
  17. evaluate_condition(condition_type, condition_value, context)
  18. end
  19. end
  20. def journey
  21. from_step.journey
  22. end
  23. private
  24. def prevent_self_reference
  25. errors.add(:to_step, "can't be the same as from_step") if from_step_id == to_step_id
  26. end
  27. def steps_in_same_journey
  28. return unless from_step && to_step
  29. if from_step.journey_id != to_step.journey_id
  30. errors.add(:base, "Steps must belong to the same journey")
  31. end
  32. end
  33. def evaluate_condition(condition_type, condition_value, context)
  34. case condition_type
  35. when 'engagement_threshold'
  36. context['engagement_score'].to_f >= condition_value.to_f
  37. when 'action_completed'
  38. Array(context['completed_actions']).include?(condition_value)
  39. when 'time_elapsed'
  40. context['time_elapsed'].to_i >= condition_value.to_i
  41. when 'form_submitted'
  42. context['submitted_forms']&.include?(condition_value)
  43. when 'link_clicked'
  44. context['clicked_links']&.include?(condition_value)
  45. when 'purchase_made'
  46. context['purchases']&.any? { |p| p['product_id'] == condition_value }
  47. when 'score_range'
  48. score = context['score'].to_f
  49. score >= condition_value['min'].to_f && score <= condition_value['max'].to_f
  50. else
  51. true
  52. end
  53. end
  54. end

app/models/suggestion_feedback.rb

0.0% lines covered

88 relevant lines. 0 lines covered and 88 lines missed.
    
  1. class SuggestionFeedback < ApplicationRecord
  2. belongs_to :journey
  3. belongs_to :journey_step
  4. belongs_to :user
  5. FEEDBACK_TYPES = %w[
  6. suggestion_quality
  7. relevance
  8. usefulness
  9. timing
  10. channel_fit
  11. content_appropriateness
  12. implementation_ease
  13. expected_results
  14. ].freeze
  15. validates :feedback_type, inclusion: { in: FEEDBACK_TYPES }
  16. validates :rating, numericality: { in: 1..5 }, allow_nil: true
  17. validates :selected, inclusion: { in: [true, false] }
  18. scope :positive, -> { where('rating >= ?', 4) }
  19. scope :negative, -> { where('rating <= ?', 2) }
  20. scope :selected, -> { where(selected: true) }
  21. scope :by_feedback_type, ->(type) { where(feedback_type: type) }
  22. scope :recent, -> { where('created_at >= ?', 30.days.ago) }
  23. # Scopes for analytics
  24. scope :for_content_type, ->(content_type) {
  25. joins(:journey_step).where(journey_steps: { content_type: content_type })
  26. }
  27. scope :for_stage, ->(stage) {
  28. joins(:journey_step).where(journey_steps: { stage: stage })
  29. }
  30. scope :for_channel, ->(channel) {
  31. joins(:journey_step).where(journey_steps: { channel: channel })
  32. }
  33. # Class methods for analytics
  34. def self.average_rating_by_type
  35. group(:feedback_type).average(:rating)
  36. end
  37. def self.selection_rate_by_content_type
  38. joins(:journey_step)
  39. .group('journey_steps.content_type')
  40. .group(:selected)
  41. .count
  42. .transform_keys { |key| key.is_a?(Array) ? { content_type: key[0], selected: key[1] } : key }
  43. end
  44. def self.selection_rate_by_stage
  45. joins(:journey_step)
  46. .group('journey_steps.stage')
  47. .group(:selected)
  48. .count
  49. .transform_keys { |key| key.is_a?(Array) ? { stage: key[0], selected: key[1] } : key }
  50. end
  51. def self.top_performing_suggestions(limit = 10)
  52. where(selected: true)
  53. .group(:suggested_step_id)
  54. .order('COUNT(*) DESC')
  55. .limit(limit)
  56. .count
  57. end
  58. def self.feedback_trends(days = 30)
  59. where('created_at >= ?', days.days.ago)
  60. .group_by_day(:created_at)
  61. .group(:feedback_type)
  62. .average(:rating)
  63. end
  64. # Instance methods
  65. def positive?
  66. rating && rating >= 4
  67. end
  68. def negative?
  69. rating && rating <= 2
  70. end
  71. def neutral?
  72. rating && rating == 3
  73. end
  74. def suggested_step_data
  75. metadata['suggested_step_data']
  76. end
  77. def ai_provider
  78. metadata['provider']
  79. end
  80. def feedback_timestamp
  81. metadata['timestamp']
  82. end
  83. # Validation helpers
  84. def validate_rating_for_feedback_type
  85. case feedback_type
  86. when 'suggestion_quality', 'relevance', 'usefulness'
  87. errors.add(:rating, "is required for #{feedback_type}") if rating.blank?
  88. end
  89. end
  90. private
  91. validate :validate_rating_for_feedback_type
  92. end

app/models/user.rb

62.69% lines covered

67 relevant lines. 42 lines covered and 25 lines missed.
    
  1. 1 class User < ApplicationRecord
  2. 1 has_secure_password
  3. 1 has_many :sessions, dependent: :destroy
  4. 1 has_one_attached :avatar
  5. 1 has_many :activities, dependent: :destroy
  6. 1 has_many :journeys, dependent: :destroy
  7. 1 has_many :journey_executions, dependent: :destroy
  8. 1 has_many :personas, dependent: :destroy
  9. 1 has_many :campaigns, dependent: :destroy
  10. 1 has_many :journey_analytics, class_name: 'JourneyAnalytics', dependent: :destroy
  11. 1 has_many :conversion_funnels, dependent: :destroy
  12. 1 has_many :journey_metrics, dependent: :destroy
  13. 1 has_many :ab_tests, dependent: :destroy
  14. 1 has_many :brands, dependent: :destroy
  15. # Self-referential association for suspension tracking
  16. 1 belongs_to :suspended_by, class_name: "User", optional: true
  17. 1 normalizes :email_address, with: ->(e) { e.strip.downcase }
  18. 1 validates :email_address, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
  19. 1 validates :password, length: { minimum: 6 }, if: -> { new_record? || password.present? }
  20. # Profile validations
  21. 1 validates :full_name, length: { maximum: 100 }
  22. 1 validates :bio, length: { maximum: 500 }
  23. 1 validates :phone_number, format: { with: /\A[\d\s\-\+\(\)]+\z/, allow_blank: true }
  24. 1 validates :company, length: { maximum: 100 }
  25. 1 validates :job_title, length: { maximum: 100 }
  26. 1 validates :timezone, inclusion: { in: ActiveSupport::TimeZone.all.map(&:name) }, allow_blank: true
  27. # Avatar validations
  28. 1 validate :acceptable_avatar
  29. # Role-based access control
  30. 1 enum :role, { marketer: 0, team_member: 1, admin: 2 }
  31. # Helper methods for role checking
  32. 1 def marketer?
  33. role == "marketer"
  34. end
  35. 1 def team_member?
  36. role == "team_member"
  37. end
  38. 1 def admin?
  39. role == "admin"
  40. end
  41. # Password reset token generation
  42. 1 def password_reset_token
  43. signed_id(purpose: :password_reset, expires_in: 15.minutes)
  44. end
  45. # Find user by password reset token
  46. 1 def self.find_by_password_reset_token!(token)
  47. find_signed!(token, purpose: :password_reset)
  48. end
  49. # Profile helpers
  50. 1 def display_name
  51. full_name.presence || email_address.split("@").first
  52. end
  53. # Account locking
  54. 1 def locked?
  55. locked_at.present?
  56. end
  57. 1 def unlock!
  58. update!(locked_at: nil, lock_reason: nil)
  59. end
  60. 1 def lock!(reason = "Account locked for security reasons")
  61. update!(locked_at: Time.current, lock_reason: reason)
  62. end
  63. # Account suspension (different from locking - this is admin-initiated)
  64. 1 def suspended?
  65. suspended_at.present?
  66. end
  67. 1 def suspend!(reason:, by:)
  68. update!(
  69. suspended_at: Time.current,
  70. suspension_reason: reason,
  71. suspended_by: by
  72. )
  73. end
  74. 1 def unsuspend!
  75. update!(
  76. suspended_at: nil,
  77. suspension_reason: nil,
  78. suspended_by: nil
  79. )
  80. end
  81. # Check if account is accessible (not locked or suspended)
  82. 1 def account_accessible?
  83. !locked? && !suspended?
  84. end
  85. 1 def avatar_variant(size)
  86. return unless avatar.attached?
  87. case size
  88. when :thumb
  89. avatar.variant(resize_to_limit: [50, 50])
  90. when :medium
  91. avatar.variant(resize_to_limit: [200, 200])
  92. when :large
  93. avatar.variant(resize_to_limit: [400, 400])
  94. else
  95. avatar
  96. end
  97. end
  98. 1 private
  99. 1 def acceptable_avatar
  100. return unless avatar.attached?
  101. unless avatar.blob.byte_size <= 5.megabyte
  102. errors.add(:avatar, "is too big (should be at most 5MB)")
  103. end
  104. acceptable_types = ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"]
  105. unless acceptable_types.include?(avatar.blob.content_type)
  106. errors.add(:avatar, "must be a JPEG, PNG, GIF, or WebP")
  107. end
  108. end
  109. end

app/models/user_activity.rb

45.45% lines covered

55 relevant lines. 25 lines covered and 30 lines missed.
    
  1. 1 class UserActivity < ApplicationRecord
  2. 1 belongs_to :user
  3. # Constants for activity types
  4. ACTIVITY_TYPES = {
  5. 1 login: 'login',
  6. logout: 'logout',
  7. create: 'create',
  8. update: 'update',
  9. delete: 'delete',
  10. view: 'view',
  11. download: 'download',
  12. upload: 'upload',
  13. failed_login: 'failed_login',
  14. password_reset: 'password_reset',
  15. profile_update: 'profile_update',
  16. suspicious_activity: 'suspicious_activity'
  17. }.freeze
  18. # Suspicious activity patterns
  19. SUSPICIOUS_PATTERNS = {
  20. 1 rapid_requests: { threshold: 100, window: 1.minute },
  21. failed_logins: { threshold: 5, window: 15.minutes },
  22. unusual_hours: { start_hour: 2, end_hour: 5 }, # 2 AM - 5 AM
  23. mass_downloads: { threshold: 50, window: 10.minutes }
  24. }.freeze
  25. # Validations
  26. 1 validates :action, presence: true
  27. 1 validates :controller_name, presence: true
  28. 1 validates :action_name, presence: true
  29. 1 validates :ip_address, presence: true
  30. 1 validates :performed_at, presence: true
  31. # Scopes
  32. 1 scope :recent, -> { order(performed_at: :desc) }
  33. 1 scope :by_user, ->(user) { where(user: user) }
  34. 1 scope :by_action, ->(action) { where(action: action) }
  35. 1 scope :by_date_range, ->(start_date, end_date) { where(performed_at: start_date..end_date) }
  36. 1 scope :suspicious, -> { where(action: ACTIVITY_TYPES[:suspicious_activity]) }
  37. 1 scope :failed_logins, -> { where(action: ACTIVITY_TYPES[:failed_login]) }
  38. # Callbacks
  39. 1 before_validation :set_performed_at
  40. 1 after_create :check_for_suspicious_activity
  41. # Class methods
  42. 1 def self.log_activity(user, action, options = {})
  43. create!(
  44. user: user,
  45. action: action,
  46. controller_name: options[:controller_name] || 'unknown',
  47. action_name: options[:action_name] || 'unknown',
  48. resource_type: options[:resource_type],
  49. resource_id: options[:resource_id],
  50. ip_address: options[:ip_address] || '0.0.0.0',
  51. user_agent: options[:user_agent],
  52. request_params: options[:request_params],
  53. metadata: options[:metadata] || {},
  54. performed_at: Time.current
  55. )
  56. end
  57. 1 def self.check_user_suspicious_activity(user)
  58. suspicious_activities = []
  59. # Check for rapid requests
  60. recent_count = by_user(user).where(performed_at: SUSPICIOUS_PATTERNS[:rapid_requests][:window].ago..Time.current).count
  61. if recent_count > SUSPICIOUS_PATTERNS[:rapid_requests][:threshold]
  62. suspicious_activities << "Rapid requests detected: #{recent_count} requests in #{SUSPICIOUS_PATTERNS[:rapid_requests][:window].inspect}"
  63. end
  64. # Check for multiple failed logins
  65. failed_login_count = by_user(user).failed_logins.where(performed_at: SUSPICIOUS_PATTERNS[:failed_logins][:window].ago..Time.current).count
  66. if failed_login_count >= SUSPICIOUS_PATTERNS[:failed_logins][:threshold]
  67. suspicious_activities << "Multiple failed login attempts: #{failed_login_count} attempts"
  68. end
  69. # Check for unusual hour activity
  70. unusual_hour = SUSPICIOUS_PATTERNS[:unusual_hours]
  71. current_hour = Time.current.hour
  72. if current_hour >= unusual_hour[:start_hour] && current_hour <= unusual_hour[:end_hour]
  73. suspicious_activities << "Activity during unusual hours: #{current_hour}:00"
  74. end
  75. suspicious_activities
  76. end
  77. # Instance methods
  78. 1 def suspicious?
  79. action == ACTIVITY_TYPES[:suspicious_activity]
  80. end
  81. 1 def resource
  82. return nil unless resource_type.present? && resource_id.present?
  83. resource_type.constantize.find_by(id: resource_id)
  84. rescue NameError
  85. nil
  86. end
  87. 1 def description
  88. case action
  89. when ACTIVITY_TYPES[:login]
  90. "User logged in"
  91. when ACTIVITY_TYPES[:logout]
  92. "User logged out"
  93. when ACTIVITY_TYPES[:failed_login]
  94. "Failed login attempt"
  95. when ACTIVITY_TYPES[:password_reset]
  96. "Password reset requested"
  97. when ACTIVITY_TYPES[:profile_update]
  98. "Profile updated"
  99. else
  100. "#{action.humanize} #{resource_type}" if resource_type.present?
  101. end
  102. end
  103. 1 private
  104. 1 def set_performed_at
  105. self.performed_at ||= Time.current
  106. end
  107. 1 def check_for_suspicious_activity
  108. return unless user.present?
  109. suspicious_activities = self.class.check_user_suspicious_activity(user)
  110. if suspicious_activities.any?
  111. self.class.log_activity(
  112. user,
  113. ACTIVITY_TYPES[:suspicious_activity],
  114. metadata: { reasons: suspicious_activities },
  115. ip_address: ip_address,
  116. user_agent: user_agent
  117. )
  118. # Trigger alert notification
  119. # Note: Using SuspiciousActivityAlertJob instead of direct mailer call
  120. # to handle both admin notification and potential user lockout
  121. Rails.logger.warn "Suspicious UserActivity detected for user #{user.email_address}: #{suspicious_activities.join(', ')}"
  122. end
  123. end
  124. end

app/policies/application_policy.rb

0.0% lines covered

39 relevant lines. 0 lines covered and 39 lines missed.
    
  1. # frozen_string_literal: true
  2. class ApplicationPolicy
  3. attr_reader :user, :record
  4. def initialize(user, record)
  5. @user = user
  6. @record = record
  7. end
  8. def index?
  9. false
  10. end
  11. def show?
  12. false
  13. end
  14. def create?
  15. false
  16. end
  17. def new?
  18. create?
  19. end
  20. def update?
  21. false
  22. end
  23. def edit?
  24. update?
  25. end
  26. def destroy?
  27. false
  28. end
  29. class Scope
  30. def initialize(user, scope)
  31. @user = user
  32. @scope = scope
  33. end
  34. def resolve
  35. raise NoMethodError, "You must define #resolve in #{self.class}"
  36. end
  37. private
  38. attr_reader :user, :scope
  39. end
  40. end

app/policies/rails_admin_policy.rb

0.0% lines covered

41 relevant lines. 0 lines covered and 41 lines missed.
    
  1. class RailsAdminPolicy < ApplicationPolicy
  2. def dashboard?
  3. user&.admin?
  4. end
  5. def index?
  6. user&.admin?
  7. end
  8. def show?
  9. user&.admin?
  10. end
  11. def new?
  12. user&.admin?
  13. end
  14. def edit?
  15. user&.admin?
  16. end
  17. def destroy?
  18. user&.admin?
  19. end
  20. def export?
  21. user&.admin?
  22. end
  23. def bulk_delete?
  24. user&.admin?
  25. end
  26. def show_in_app?
  27. user&.admin?
  28. end
  29. def history_index?
  30. user&.admin?
  31. end
  32. def history_show?
  33. user&.admin?
  34. end
  35. def suspend?
  36. user&.admin?
  37. end
  38. def unsuspend?
  39. user&.admin?
  40. end
  41. end

app/policies/user_policy.rb

0.0% lines covered

32 relevant lines. 0 lines covered and 32 lines missed.
    
  1. class UserPolicy < ApplicationPolicy
  2. # Allow users to view their own profile or admins to view any profile
  3. def show?
  4. user == record || user.admin?
  5. end
  6. # Allow users to update their own profile or admins to update any profile
  7. def update?
  8. user == record || user.admin?
  9. end
  10. # Only admins can view the user index
  11. def index?
  12. user.admin?
  13. end
  14. # Only admins can delete users (but not themselves)
  15. def destroy?
  16. user.admin? && user != record
  17. end
  18. # Only admins can change user roles (but not their own)
  19. def change_role?
  20. user.admin? && user != record
  21. end
  22. # Only admins can suspend users (but not themselves)
  23. def suspend?
  24. user.admin? && user != record
  25. end
  26. # Only admins can unsuspend users
  27. def unsuspend?
  28. user.admin?
  29. end
  30. class Scope < ApplicationPolicy::Scope
  31. def resolve
  32. if user.admin?
  33. scope.all
  34. else
  35. scope.where(id: user.id)
  36. end
  37. end
  38. end
  39. end

app/services/ab_test_analytics_service.rb

0.0% lines covered

539 relevant lines. 0 lines covered and 539 lines missed.
    
  1. class AbTestAnalyticsService
  2. def initialize(ab_test)
  3. @ab_test = ab_test
  4. end
  5. def generate_full_analysis
  6. {
  7. test_overview: test_overview,
  8. variant_performance: variant_performance_analysis,
  9. statistical_analysis: statistical_analysis,
  10. confidence_intervals: confidence_intervals_analysis,
  11. power_analysis: power_analysis,
  12. recommendations: generate_recommendations,
  13. historical_comparison: historical_comparison,
  14. segments_analysis: segments_analysis
  15. }
  16. end
  17. def test_overview
  18. {
  19. test_id: @ab_test.id,
  20. test_name: @ab_test.name,
  21. status: @ab_test.status,
  22. hypothesis: @ab_test.hypothesis,
  23. test_type: @ab_test.test_type,
  24. duration_days: @ab_test.duration_days,
  25. confidence_level: @ab_test.confidence_level,
  26. significance_threshold: @ab_test.significance_threshold,
  27. total_variants: @ab_test.ab_test_variants.count,
  28. total_visitors: @ab_test.ab_test_variants.sum(:total_visitors),
  29. total_conversions: @ab_test.ab_test_variants.sum(:conversions),
  30. overall_conversion_rate: calculate_overall_conversion_rate,
  31. winner_declared: @ab_test.winner_declared?,
  32. winner_variant: @ab_test.winner_variant&.name
  33. }
  34. end
  35. def variant_performance_analysis
  36. variants = @ab_test.ab_test_variants.includes(:journey)
  37. performance_data = variants.map do |variant|
  38. {
  39. variant_id: variant.id,
  40. variant_name: variant.name,
  41. is_control: variant.is_control?,
  42. journey_name: variant.journey.name,
  43. traffic_percentage: variant.traffic_percentage,
  44. total_visitors: variant.total_visitors,
  45. conversions: variant.conversions,
  46. conversion_rate: variant.conversion_rate,
  47. confidence_interval: variant.confidence_interval_range,
  48. lift_vs_control: variant.lift_vs_control,
  49. significance_vs_control: variant.significance_vs_control,
  50. sample_size_adequate: variant.sample_size_adequate?,
  51. statistical_power: variant.statistical_power,
  52. performance_grade: calculate_variant_grade(variant)
  53. }
  54. end
  55. # Add relative rankings
  56. performance_data.sort_by! { |v| -v[:conversion_rate] }
  57. performance_data.each_with_index do |variant_data, index|
  58. variant_data[:performance_rank] = index + 1
  59. end
  60. {
  61. variants: performance_data,
  62. best_performer: performance_data.first,
  63. control_performance: performance_data.find { |v| v[:is_control] },
  64. performance_spread: calculate_performance_spread(performance_data)
  65. }
  66. end
  67. def statistical_analysis
  68. return {} unless @ab_test.running? || @ab_test.completed?
  69. control_variant = @ab_test.ab_test_variants.find_by(is_control: true)
  70. treatment_variants = @ab_test.ab_test_variants.where(is_control: false)
  71. return {} unless control_variant
  72. statistical_results = {}
  73. treatment_variants.each do |treatment|
  74. stat_test = perform_statistical_test(control_variant, treatment)
  75. statistical_results[treatment.name] = {
  76. z_score: stat_test[:z_score],
  77. p_value: stat_test[:p_value],
  78. significance_level: stat_test[:significance_level],
  79. is_significant: stat_test[:is_significant],
  80. effect_size: stat_test[:effect_size],
  81. power_estimate: estimate_statistical_power(control_variant, treatment),
  82. sample_size_recommendation: recommend_sample_size(control_variant, treatment)
  83. }
  84. end
  85. {
  86. control_variant: control_variant.name,
  87. treatment_results: statistical_results,
  88. overall_test_power: calculate_overall_test_power(statistical_results),
  89. significance_achieved: @ab_test.statistical_significance_reached?
  90. }
  91. end
  92. def confidence_intervals_analysis
  93. variants = @ab_test.ab_test_variants
  94. confidence_data = variants.map do |variant|
  95. ci_range = variant.confidence_interval_range
  96. margin_of_error = (ci_range[1] - ci_range[0]) / 2
  97. {
  98. variant_name: variant.name,
  99. conversion_rate: variant.conversion_rate,
  100. confidence_interval: ci_range,
  101. margin_of_error: margin_of_error.round(2),
  102. precision_level: classify_precision(margin_of_error),
  103. sample_size: variant.total_visitors
  104. }
  105. end
  106. {
  107. variants_confidence: confidence_data,
  108. overlapping_intervals: identify_overlapping_intervals(confidence_data),
  109. precision_assessment: assess_overall_precision(confidence_data)
  110. }
  111. end
  112. def power_analysis
  113. control_variant = @ab_test.ab_test_variants.find_by(is_control: true)
  114. return {} unless control_variant
  115. treatment_variants = @ab_test.ab_test_variants.where(is_control: false)
  116. power_results = treatment_variants.map do |treatment|
  117. current_power = estimate_statistical_power(control_variant, treatment)
  118. # Calculate required sample sizes for different effect sizes
  119. required_samples = {
  120. small_effect: calculate_required_sample_size(control_variant, 0.1),
  121. medium_effect: calculate_required_sample_size(control_variant, 0.2),
  122. large_effect: calculate_required_sample_size(control_variant, 0.5)
  123. }
  124. {
  125. variant_name: treatment.name,
  126. current_power: current_power,
  127. current_sample_size: treatment.total_visitors,
  128. required_samples_for_power_80: required_samples,
  129. days_to_adequate_power: estimate_days_to_power(treatment),
  130. power_assessment: assess_power_level(current_power)
  131. }
  132. end
  133. {
  134. control_variant: control_variant.name,
  135. treatment_power_analysis: power_results,
  136. overall_test_adequacy: assess_overall_test_adequacy(power_results)
  137. }
  138. end
  139. def generate_recommendations
  140. recommendations = []
  141. # Sample size recommendations
  142. if total_sample_size_adequate?
  143. recommendations << create_recommendation(
  144. 'sample_size',
  145. 'sufficient',
  146. 'Sample Size Adequate',
  147. 'Current sample size is sufficient for reliable results.'
  148. )
  149. else
  150. recommendations << create_recommendation(
  151. 'sample_size',
  152. 'insufficient',
  153. 'Increase Sample Size',
  154. 'Current sample size may not be sufficient for reliable statistical conclusions.',
  155. ['Continue test to gather more data', 'Consider increasing traffic allocation']
  156. )
  157. end
  158. # Statistical significance recommendations
  159. if @ab_test.statistical_significance_reached?
  160. if @ab_test.winner_declared?
  161. recommendations << create_recommendation(
  162. 'implementation',
  163. 'ready',
  164. 'Implement Winning Variant',
  165. "#{@ab_test.winner_variant.name} has shown statistically significant improvement.",
  166. ['Deploy winning variant to all traffic', 'Monitor performance post-implementation']
  167. )
  168. else
  169. recommendations << create_recommendation(
  170. 'analysis',
  171. 'review_needed',
  172. 'Review Statistical Results',
  173. 'Significance reached but no clear winner declared.',
  174. ['Review business impact of variants', 'Consider practical significance vs statistical significance']
  175. )
  176. end
  177. else
  178. recommendations << create_recommendation(
  179. 'continue_testing',
  180. 'in_progress',
  181. 'Continue Test',
  182. 'More data needed to reach statistical significance.',
  183. ['Continue test for more time', 'Consider increasing traffic if possible']
  184. )
  185. end
  186. # Performance-based recommendations
  187. variant_analysis = variant_performance_analysis
  188. control_performance = variant_analysis[:control_performance]
  189. best_performer = variant_analysis[:best_performer]
  190. if best_performer && control_performance
  191. lift = best_performer[:lift_vs_control]
  192. if lift > 20
  193. recommendations << create_recommendation(
  194. 'high_impact',
  195. 'significant_improvement',
  196. 'High Impact Variant Identified',
  197. "#{best_performer[:variant_name]} shows #{lift}% improvement over control.",
  198. ['Fast-track implementation if significance is reached', 'Analyze successful elements for future tests']
  199. )
  200. elsif lift < -10
  201. recommendations << create_recommendation(
  202. 'performance_issue',
  203. 'negative_impact',
  204. 'Negative Performance Detected',
  205. "Best variant still underperforms control by #{lift.abs}%.",
  206. ['Stop test and revert to control', 'Analyze failure factors for future tests']
  207. )
  208. end
  209. end
  210. # Duration recommendations
  211. if @ab_test.duration_days > 30
  212. recommendations << create_recommendation(
  213. 'duration',
  214. 'long_running',
  215. 'Long-Running Test',
  216. 'Test has been running for over 30 days.',
  217. ['Consider concluding test based on current data', 'Evaluate if external factors may be affecting results']
  218. )
  219. end
  220. recommendations
  221. end
  222. def historical_comparison
  223. # Compare with previous A/B tests in the same campaign
  224. campaign = @ab_test.campaign
  225. previous_tests = campaign.ab_tests.completed.where.not(id: @ab_test.id)
  226. .order(created_at: :desc)
  227. .limit(5)
  228. return {} if previous_tests.empty?
  229. historical_data = previous_tests.map do |test|
  230. {
  231. test_name: test.name,
  232. duration_days: test.duration_days,
  233. winner_conversion_rate: test.winner_variant&.conversion_rate || 0,
  234. total_participants: test.ab_test_variants.sum(:total_visitors),
  235. lift_achieved: calculate_historical_lift(test),
  236. lessons_learned: extract_lessons_learned(test)
  237. }
  238. end
  239. {
  240. previous_tests: historical_data,
  241. average_lift: historical_data.map { |t| t[:lift_achieved] }.sum / historical_data.count,
  242. success_rate: calculate_historical_success_rate(previous_tests),
  243. patterns: identify_historical_patterns(historical_data)
  244. }
  245. end
  246. def segments_analysis
  247. # This would analyze performance across different user segments
  248. # For now, return placeholder data that would integrate with actual segment tracking
  249. segments = {
  250. demographic: analyze_demographic_segments,
  251. behavioral: analyze_behavioral_segments,
  252. temporal: analyze_temporal_segments,
  253. acquisition_channel: analyze_channel_segments
  254. }
  255. {
  256. segments_breakdown: segments,
  257. significant_segments: identify_significant_segments(segments),
  258. recommendations: generate_segment_recommendations(segments)
  259. }
  260. end
  261. private
  262. def calculate_overall_conversion_rate
  263. total_visitors = @ab_test.ab_test_variants.sum(:total_visitors)
  264. total_conversions = @ab_test.ab_test_variants.sum(:conversions)
  265. return 0 if total_visitors == 0
  266. (total_conversions.to_f / total_visitors * 100).round(2)
  267. end
  268. def calculate_variant_grade(variant)
  269. score = variant.conversion_rate
  270. case score
  271. when 10..Float::INFINITY then 'A'
  272. when 7..9.99 then 'B'
  273. when 5..6.99 then 'C'
  274. when 3..4.99 then 'D'
  275. else 'F'
  276. end
  277. end
  278. def calculate_performance_spread(performance_data)
  279. conversion_rates = performance_data.map { |v| v[:conversion_rate] }
  280. max_rate = conversion_rates.max
  281. min_rate = conversion_rates.min
  282. {
  283. max_conversion_rate: max_rate,
  284. min_conversion_rate: min_rate,
  285. spread: (max_rate - min_rate).round(2),
  286. coefficient_of_variation: calculate_coefficient_of_variation(conversion_rates)
  287. }
  288. end
  289. def perform_statistical_test(control, treatment)
  290. # Z-test for proportions
  291. p1 = control.conversion_rate / 100.0
  292. p2 = treatment.conversion_rate / 100.0
  293. n1 = control.total_visitors
  294. n2 = treatment.total_visitors
  295. return default_stat_test if n1 == 0 || n2 == 0
  296. # Pooled proportion
  297. p_pool = (control.conversions + treatment.conversions).to_f / (n1 + n2)
  298. # Standard error
  299. se = Math.sqrt(p_pool * (1 - p_pool) * (1.0/n1 + 1.0/n2))
  300. return default_stat_test if se == 0
  301. # Z-score
  302. z_score = (p2 - p1) / se
  303. # P-value (two-tailed test)
  304. p_value = 2 * (1 - normal_cdf(z_score.abs))
  305. # Effect size (Cohen's h)
  306. effect_size = 2 * (Math.asin(Math.sqrt(p2)) - Math.asin(Math.sqrt(p1)))
  307. {
  308. z_score: z_score.round(3),
  309. p_value: p_value.round(4),
  310. significance_level: classify_significance(p_value),
  311. is_significant: p_value < 0.05,
  312. effect_size: effect_size.round(3)
  313. }
  314. end
  315. def default_stat_test
  316. {
  317. z_score: 0,
  318. p_value: 1.0,
  319. significance_level: 'not_significant',
  320. is_significant: false,
  321. effect_size: 0
  322. }
  323. end
  324. def estimate_statistical_power(control, treatment)
  325. # Simplified power calculation
  326. sample_size = [control.total_visitors, treatment.total_visitors].min
  327. effect_size = (treatment.conversion_rate - control.conversion_rate).abs / 100.0
  328. case
  329. when sample_size < 100 then 0.2
  330. when sample_size < 500 && effect_size > 0.02 then 0.5
  331. when sample_size < 1000 && effect_size > 0.01 then 0.7
  332. when sample_size >= 1000 && effect_size > 0.01 then 0.8
  333. else 0.3
  334. end
  335. end
  336. def recommend_sample_size(control, treatment)
  337. # Simplified sample size calculation for 80% power
  338. baseline_rate = control.conversion_rate / 100.0
  339. effect_size = (treatment.conversion_rate - control.conversion_rate).abs / 100.0
  340. return 0 if effect_size == 0 || baseline_rate == 0
  341. # Simplified formula - in practice would use more sophisticated calculation
  342. estimated_n = (16 * baseline_rate * (1 - baseline_rate)) / (effect_size ** 2)
  343. estimated_n.round
  344. end
  345. def calculate_overall_test_power(statistical_results)
  346. return 0 if statistical_results.empty?
  347. powers = statistical_results.values.map { |result| result[:power_estimate] }
  348. (powers.sum / powers.count).round(2)
  349. end
  350. def classify_precision(margin_of_error)
  351. case margin_of_error
  352. when 0..1 then 'very_high'
  353. when 1..2 then 'high'
  354. when 2..5 then 'medium'
  355. when 5..10 then 'low'
  356. else 'very_low'
  357. end
  358. end
  359. def identify_overlapping_intervals(confidence_data)
  360. overlaps = []
  361. confidence_data.combination(2).each do |variant1, variant2|
  362. ci1 = variant1[:confidence_interval]
  363. ci2 = variant2[:confidence_interval]
  364. if intervals_overlap?(ci1, ci2)
  365. overlaps << {
  366. variant1: variant1[:variant_name],
  367. variant2: variant2[:variant_name],
  368. overlap_size: calculate_overlap_size(ci1, ci2)
  369. }
  370. end
  371. end
  372. overlaps
  373. end
  374. def assess_overall_precision(confidence_data)
  375. avg_margin = confidence_data.map { |v| v[:margin_of_error] }.sum / confidence_data.count
  376. case avg_margin
  377. when 0..2 then 'high_precision'
  378. when 2..5 then 'medium_precision'
  379. else 'low_precision'
  380. end
  381. end
  382. def total_sample_size_adequate?
  383. total_visitors = @ab_test.ab_test_variants.sum(:total_visitors)
  384. total_visitors >= 1000 # Simplified threshold
  385. end
  386. def create_recommendation(type, status, title, description, action_items = [])
  387. {
  388. type: type,
  389. status: status,
  390. title: title,
  391. description: description,
  392. action_items: action_items,
  393. priority: determine_priority(type, status)
  394. }
  395. end
  396. def determine_priority(type, status)
  397. case type
  398. when 'implementation', 'high_impact' then 'high'
  399. when 'performance_issue', 'sample_size' then 'medium'
  400. else 'low'
  401. end
  402. end
  403. def calculate_historical_lift(test)
  404. return 0 unless test.winner_variant
  405. control = test.ab_test_variants.find_by(is_control: true)
  406. return 0 unless control
  407. ((test.winner_variant.conversion_rate - control.conversion_rate) / control.conversion_rate * 100).round(1)
  408. end
  409. def extract_lessons_learned(test)
  410. # This would analyze the test results and extract key insights
  411. # For now, return placeholder insights
  412. [
  413. "#{test.test_type} tests typically require #{test.duration_days} days for significance",
  414. "Winner achieved #{calculate_historical_lift(test)}% lift"
  415. ]
  416. end
  417. def calculate_historical_success_rate(previous_tests)
  418. successful_tests = previous_tests.count { |test| test.winner_variant&.conversion_rate.to_f > 0 }
  419. return 0 if previous_tests.empty?
  420. (successful_tests.to_f / previous_tests.count * 100).round(1)
  421. end
  422. def identify_historical_patterns(historical_data)
  423. return [] if historical_data.empty?
  424. patterns = []
  425. avg_duration = historical_data.map { |t| t[:duration_days] }.sum / historical_data.count
  426. patterns << "Average test duration: #{avg_duration.round} days"
  427. avg_lift = historical_data.map { |t| t[:lift_achieved] }.sum / historical_data.count
  428. patterns << "Average lift achieved: #{avg_lift.round(1)}%"
  429. patterns
  430. end
  431. def analyze_demographic_segments
  432. # Placeholder for demographic segment analysis
  433. {
  434. age_groups: {
  435. '18-25' => { control_cr: 4.2, treatment_cr: 5.1, significance: 'not_significant' },
  436. '26-35' => { control_cr: 5.8, treatment_cr: 7.2, significance: 'significant' },
  437. '36-45' => { control_cr: 6.1, treatment_cr: 6.3, significance: 'not_significant' }
  438. }
  439. }
  440. end
  441. def analyze_behavioral_segments
  442. # Placeholder for behavioral segment analysis
  443. {
  444. engagement_level: {
  445. 'high' => { control_cr: 8.2, treatment_cr: 9.8, significance: 'significant' },
  446. 'medium' => { control_cr: 5.1, treatment_cr: 5.9, significance: 'marginally_significant' },
  447. 'low' => { control_cr: 2.8, treatment_cr: 3.1, significance: 'not_significant' }
  448. }
  449. }
  450. end
  451. def analyze_temporal_segments
  452. # Placeholder for temporal segment analysis
  453. {
  454. time_of_day: {
  455. 'morning' => { control_cr: 5.5, treatment_cr: 6.8, significance: 'significant' },
  456. 'afternoon' => { control_cr: 4.9, treatment_cr: 5.2, significance: 'not_significant' },
  457. 'evening' => { control_cr: 6.2, treatment_cr: 7.1, significance: 'marginally_significant' }
  458. }
  459. }
  460. end
  461. def analyze_channel_segments
  462. # Placeholder for acquisition channel analysis
  463. {
  464. acquisition_channel: {
  465. 'organic' => { control_cr: 7.2, treatment_cr: 8.5, significance: 'significant' },
  466. 'paid_search' => { control_cr: 4.8, treatment_cr: 5.1, significance: 'not_significant' },
  467. 'social' => { control_cr: 3.9, treatment_cr: 4.7, significance: 'marginally_significant' }
  468. }
  469. }
  470. end
  471. def identify_significant_segments(segments)
  472. significant = []
  473. segments.each do |segment_type, segment_data|
  474. segment_data.each do |segment_name, data|
  475. if data[:significance] == 'significant'
  476. significant << {
  477. segment_type: segment_type,
  478. segment_name: segment_name,
  479. control_cr: data[:control_cr],
  480. treatment_cr: data[:treatment_cr],
  481. lift: ((data[:treatment_cr] - data[:control_cr]) / data[:control_cr] * 100).round(1)
  482. }
  483. end
  484. end
  485. end
  486. significant
  487. end
  488. def generate_segment_recommendations(segments)
  489. recommendations = []
  490. significant_segments = identify_significant_segments(segments)
  491. if significant_segments.any?
  492. recommendations << "Consider targeting #{significant_segments.first[:segment_name]} segment for maximum impact"
  493. end
  494. recommendations
  495. end
  496. # Statistical helper methods
  497. def normal_cdf(x)
  498. # Simplified normal CDF approximation
  499. (1 + Math.erf(x / Math.sqrt(2))) / 2
  500. end
  501. def classify_significance(p_value)
  502. case p_value
  503. when 0..0.001 then 'highly_significant'
  504. when 0.001..0.01 then 'very_significant'
  505. when 0.01..0.05 then 'significant'
  506. when 0.05..0.1 then 'marginally_significant'
  507. else 'not_significant'
  508. end
  509. end
  510. def calculate_coefficient_of_variation(values)
  511. return 0 if values.empty?
  512. mean = values.sum.to_f / values.count
  513. return 0 if mean == 0
  514. variance = values.sum { |v| (v - mean) ** 2 } / values.count
  515. std_dev = Math.sqrt(variance)
  516. (std_dev / mean * 100).round(2)
  517. end
  518. def intervals_overlap?(ci1, ci2)
  519. ci1[0] <= ci2[1] && ci2[0] <= ci1[1]
  520. end
  521. def calculate_overlap_size(ci1, ci2)
  522. return 0 unless intervals_overlap?(ci1, ci2)
  523. overlap_start = [ci1[0], ci2[0]].max
  524. overlap_end = [ci1[1], ci2[1]].min
  525. overlap_end - overlap_start
  526. end
  527. def calculate_required_sample_size(control_variant, minimum_detectable_effect)
  528. baseline_rate = control_variant.conversion_rate / 100.0
  529. return 0 if baseline_rate == 0
  530. # Simplified sample size calculation for 80% power, 5% significance
  531. effect_size = minimum_detectable_effect
  532. z_alpha = 1.96 # 5% significance level
  533. z_beta = 0.84 # 80% power
  534. numerator = (z_alpha + z_beta) ** 2 * 2 * baseline_rate * (1 - baseline_rate)
  535. denominator = effect_size ** 2
  536. (numerator / denominator).round
  537. end
  538. def estimate_days_to_power(variant)
  539. return 'N/A' unless variant.expected_visitors_per_day > 0
  540. required_sample = recommend_sample_size(
  541. @ab_test.ab_test_variants.find_by(is_control: true),
  542. variant
  543. )
  544. additional_visitors_needed = [required_sample - variant.total_visitors, 0].max
  545. days_needed = (additional_visitors_needed / variant.expected_visitors_per_day).ceil
  546. days_needed > 0 ? days_needed : 0
  547. end
  548. def assess_power_level(power)
  549. case power
  550. when 0.8..1.0 then 'adequate'
  551. when 0.6..0.79 then 'moderate'
  552. when 0.4..0.59 then 'low'
  553. else 'insufficient'
  554. end
  555. end
  556. def assess_overall_test_adequacy(power_results)
  557. adequate_variants = power_results.count { |result| result[:power_assessment] == 'adequate' }
  558. total_variants = power_results.count
  559. case adequate_variants.to_f / total_variants
  560. when 0.8..1.0 then 'test_ready'
  561. when 0.5..0.79 then 'mostly_adequate'
  562. when 0.2..0.49 then 'needs_improvement'
  563. else 'inadequate'
  564. end
  565. end
  566. end

app/services/activity_logger.rb

0.0% lines covered

140 relevant lines. 0 lines covered and 140 lines missed.
    
  1. class ActivityLogger
  2. include Singleton
  3. SECURITY_EVENTS = %w[
  4. authentication_failure
  5. authorization_failure
  6. suspicious_activity
  7. account_locked
  8. password_reset
  9. admin_action
  10. data_export
  11. bulk_operation
  12. ].freeze
  13. PERFORMANCE_EVENTS = %w[
  14. slow_request
  15. database_slow_query
  16. cache_miss
  17. api_timeout
  18. background_job_failure
  19. ].freeze
  20. class << self
  21. delegate :log, :security, :performance, :audit, to: :instance
  22. end
  23. def initialize
  24. @logger = Rails.logger
  25. @security_logger = Rails.application.config.respond_to?(:security_logger) ?
  26. Rails.application.config.security_logger :
  27. Rails.logger
  28. end
  29. # General activity logging
  30. def log(level, message, context = {})
  31. structured_log = build_log_entry(message, context)
  32. @logger.send(level, structured_log.to_json)
  33. # Also log to database if it's an important event
  34. persist_to_database(level, message, context) if should_persist?(level, context)
  35. end
  36. # Security-specific logging
  37. def security(event_type, message, context = {})
  38. return unless SECURITY_EVENTS.include?(event_type.to_s)
  39. context[:event_type] = event_type
  40. context[:security_event] = true
  41. @security_logger.tagged('SECURITY', event_type.to_s.upcase) do
  42. @security_logger.warn build_log_entry(message, context).to_json
  43. end
  44. # Trigger notifications for critical security events
  45. notify_security_event(event_type, message, context) if critical_security_event?(event_type)
  46. # Instrument for monitoring
  47. ActiveSupport::Notifications.instrument('suspicious_activity.security',
  48. event_type: event_type,
  49. message: message,
  50. context: context
  51. )
  52. end
  53. # Performance logging
  54. def performance(metric_type, message, context = {})
  55. return unless PERFORMANCE_EVENTS.include?(metric_type.to_s)
  56. context[:metric_type] = metric_type
  57. context[:performance_event] = true
  58. @logger.tagged('PERFORMANCE', metric_type.to_s.upcase) do
  59. @logger.info build_log_entry(message, context).to_json
  60. end
  61. # Send to monitoring service
  62. send_to_monitoring(metric_type, context) if Rails.env.production?
  63. end
  64. # Audit logging for compliance
  65. def audit(action, resource, changes = {}, user = nil)
  66. audit_entry = {
  67. action: action,
  68. resource_type: resource.class.name,
  69. resource_id: resource.id,
  70. changes: sanitize_changes(changes),
  71. user_id: user&.id,
  72. user_email: user&.email_address,
  73. timestamp: Time.current.iso8601
  74. }
  75. @logger.tagged('AUDIT') do
  76. @logger.info audit_entry.to_json
  77. end
  78. # Store audit trail in database
  79. if defined?(AdminAuditLog) && user
  80. AdminAuditLog.create!(
  81. user: user,
  82. action: action,
  83. auditable: resource,
  84. change_details: changes,
  85. ip_address: Current.ip_address,
  86. user_agent: Current.user_agent
  87. )
  88. end
  89. end
  90. private
  91. def build_log_entry(message, context = {})
  92. {
  93. timestamp: Time.current.iso8601,
  94. level: context[:level] || 'info',
  95. message: message,
  96. request_id: Current.request_id || Thread.current[:request_id],
  97. user_id: Current.user&.id,
  98. ip_address: Current.ip_address,
  99. user_agent: Current.user_agent,
  100. session_id: Current.session_id,
  101. context: context.except(:level)
  102. }.compact
  103. end
  104. def should_persist?(level, context)
  105. # Persist warnings, errors, and security events
  106. %w[warn error fatal].include?(level.to_s) ||
  107. context[:security_event] ||
  108. context[:audit_event]
  109. end
  110. def persist_to_database(level, message, context)
  111. return unless Current.user
  112. Activity.create!(
  113. user: Current.user,
  114. action: context[:action] || 'system_log',
  115. controller: context[:controller] || 'system',
  116. metadata: {
  117. message: message,
  118. level: level,
  119. context: context
  120. },
  121. suspicious: context[:security_event] || level.to_s == 'error'
  122. )
  123. rescue => e
  124. Rails.logger.error "Failed to persist log to database: #{e.message}"
  125. end
  126. def critical_security_event?(event_type)
  127. %w[suspicious_activity account_locked authorization_failure].include?(event_type.to_s)
  128. end
  129. def notify_security_event(event_type, message, context)
  130. # Queue notification job
  131. if defined?(SecurityNotificationJob)
  132. SecurityNotificationJob.perform_later(
  133. event_type: event_type,
  134. message: message,
  135. context: context
  136. )
  137. end
  138. end
  139. def send_to_monitoring(metric_type, context)
  140. # Integration with monitoring services like DataDog, New Relic, etc.
  141. # This is a placeholder for actual monitoring integration
  142. Rails.logger.info "Monitoring metric: #{metric_type} - #{context.to_json}"
  143. end
  144. def sanitize_changes(changes)
  145. # Remove sensitive data from audit logs
  146. sensitive_fields = %w[password password_confirmation password_digest token secret]
  147. changes.deep_dup.tap do |sanitized|
  148. sensitive_fields.each do |field|
  149. sanitized.delete(field)
  150. sanitized.delete(field.to_sym)
  151. end
  152. end
  153. end
  154. end

app/services/activity_report_service.rb

0.0% lines covered

257 relevant lines. 0 lines covered and 257 lines missed.
    
  1. class ActivityReportService
  2. attr_reader :user, :start_date, :end_date
  3. def initialize(user, start_date: 30.days.ago, end_date: Time.current)
  4. @user = user
  5. @start_date = start_date.beginning_of_day
  6. @end_date = end_date.end_of_day
  7. end
  8. # Class method for recurring job
  9. def self.generate_daily_reports
  10. Rails.logger.info "Generating daily activity reports..."
  11. # Generate reports for all admin users
  12. User.admin.find_each do |admin|
  13. report = new(admin, start_date: 1.day.ago).generate_report
  14. # Send email if configured
  15. if Rails.application.config.activity_alerts.enabled && admin.notification_email?
  16. AdminMailer.daily_activity_report(admin, report).deliver_later
  17. end
  18. # Log completion
  19. ActivityLogger.log(:info, "Daily report generated for admin", {
  20. admin_id: admin.id,
  21. total_activities: report[:summary][:total_activities]
  22. })
  23. end
  24. Rails.logger.info "Daily activity reports completed."
  25. end
  26. def generate_report
  27. {
  28. summary: generate_summary,
  29. activity_breakdown: activity_breakdown,
  30. suspicious_activities: suspicious_activity_summary,
  31. performance_metrics: performance_metrics,
  32. security_events: security_events,
  33. access_patterns: access_patterns,
  34. device_usage: device_usage,
  35. recommendations: generate_recommendations
  36. }
  37. end
  38. def generate_summary
  39. activities = user_activities
  40. {
  41. total_activities: activities.count,
  42. date_range: {
  43. start: start_date,
  44. end: end_date
  45. },
  46. most_active_day: most_active_day(activities),
  47. average_daily_activities: average_daily_activities(activities),
  48. suspicious_count: activities.suspicious.count,
  49. failed_requests: activities.failed_requests.count,
  50. unique_ips: activities.distinct.count(:ip_address),
  51. unique_sessions: activities.distinct.count(:session_id)
  52. }
  53. end
  54. def activity_breakdown
  55. activities = user_activities
  56. # Group by controller and action
  57. breakdown = activities
  58. .group(:controller, :action)
  59. .count
  60. .map { |k, v| { controller: k[0], action: k[1], count: v } }
  61. .sort_by { |item| -item[:count] }
  62. # Add percentage
  63. total = activities.count
  64. breakdown.each do |item|
  65. item[:percentage] = ((item[:count].to_f / total) * 100).round(2)
  66. end
  67. breakdown
  68. end
  69. def suspicious_activity_summary
  70. suspicious = user_activities.suspicious
  71. return { count: 0, events: [] } if suspicious.empty?
  72. {
  73. count: suspicious.count,
  74. events: suspicious.map do |activity|
  75. {
  76. occurred_at: activity.occurred_at,
  77. action: activity.full_action,
  78. ip_address: activity.ip_address,
  79. reasons: activity.metadata['suspicious_reasons'] || [],
  80. user_agent: activity.user_agent
  81. }
  82. end,
  83. patterns: analyze_suspicious_patterns(suspicious)
  84. }
  85. end
  86. def performance_metrics
  87. activities = user_activities.where.not(response_time: nil)
  88. return {} if activities.empty?
  89. response_times = activities.pluck(:response_time)
  90. {
  91. average_response_time: (response_times.sum / response_times.size * 1000).round(2),
  92. median_response_time: (median(response_times) * 1000).round(2),
  93. slowest_actions: slowest_actions(activities),
  94. response_time_distribution: response_time_distribution(response_times)
  95. }
  96. end
  97. def security_events
  98. events = []
  99. # Failed login attempts
  100. failed_logins = user_activities
  101. .where(controller: 'sessions', action: 'create')
  102. .failed_requests
  103. if failed_logins.any?
  104. events << {
  105. type: 'failed_login_attempts',
  106. count: failed_logins.count,
  107. last_attempt: failed_logins.maximum(:occurred_at),
  108. ip_addresses: failed_logins.distinct.pluck(:ip_address)
  109. }
  110. end
  111. # Authorization failures
  112. auth_failures = user_activities
  113. .where("metadata LIKE ?", '%NotAuthorizedError%')
  114. if auth_failures.any?
  115. events << {
  116. type: 'authorization_failures',
  117. count: auth_failures.count,
  118. resources: auth_failures.map { |a| a.full_action }.uniq
  119. }
  120. end
  121. # Account lockouts
  122. if user.locked_at.present? && user.locked_at >= start_date
  123. events << {
  124. type: 'account_locked',
  125. locked_at: user.locked_at,
  126. reason: user.lock_reason
  127. }
  128. end
  129. events
  130. end
  131. def access_patterns
  132. activities = user_activities
  133. # Group by hour of day
  134. hourly_pattern = activities
  135. .group_by { |a| a.occurred_at.hour }
  136. .transform_values(&:count)
  137. .sort.to_h
  138. # Group by day of week
  139. daily_pattern = activities
  140. .group_by { |a| a.occurred_at.strftime('%A') }
  141. .transform_values(&:count)
  142. # Most accessed resources
  143. top_resources = activities
  144. .group(:request_path)
  145. .count
  146. .sort_by { |_, count| -count }
  147. .first(10)
  148. .to_h
  149. {
  150. hourly_distribution: hourly_pattern,
  151. daily_distribution: daily_pattern,
  152. top_resources: top_resources,
  153. access_times: {
  154. first_access: activities.minimum(:occurred_at),
  155. last_access: activities.maximum(:occurred_at),
  156. most_active_hour: hourly_pattern.max_by { |_, v| v }&.first,
  157. most_active_day: daily_pattern.max_by { |_, v| v }&.first
  158. }
  159. }
  160. end
  161. def device_usage
  162. activities = user_activities
  163. {
  164. devices: activities.group(:device_type).count,
  165. browsers: activities.group(:browser_name).count,
  166. operating_systems: activities.group(:os_name).count,
  167. unique_user_agents: activities.distinct.count(:user_agent)
  168. }
  169. end
  170. private
  171. def user_activities
  172. @user_activities ||= user.activities
  173. .where(occurred_at: start_date..end_date)
  174. .includes(:user)
  175. end
  176. def most_active_day(activities)
  177. return nil if activities.empty?
  178. activities
  179. .group_by { |a| a.occurred_at.to_date }
  180. .max_by { |_, acts| acts.count }
  181. &.first
  182. end
  183. def average_daily_activities(activities)
  184. days = ((end_date - start_date) / 1.day).ceil
  185. (activities.count.to_f / days).round(2)
  186. end
  187. def analyze_suspicious_patterns(suspicious_activities)
  188. patterns = {}
  189. # Group by reason
  190. reasons = suspicious_activities
  191. .flat_map { |a| a.metadata['suspicious_reasons'] || [] }
  192. .tally
  193. patterns[:by_reason] = reasons
  194. # Time-based patterns
  195. patterns[:by_hour] = suspicious_activities
  196. .group_by { |a| a.occurred_at.hour }
  197. .transform_values(&:count)
  198. # IP-based patterns
  199. patterns[:by_ip] = suspicious_activities
  200. .group(:ip_address)
  201. .count
  202. .sort_by { |_, count| -count }
  203. .first(5)
  204. .to_h
  205. patterns
  206. end
  207. def slowest_actions(activities)
  208. activities
  209. .order(response_time: :desc)
  210. .limit(10)
  211. .map do |activity|
  212. {
  213. action: activity.full_action,
  214. response_time_ms: (activity.response_time * 1000).round(2),
  215. occurred_at: activity.occurred_at,
  216. path: activity.request_path
  217. }
  218. end
  219. end
  220. def response_time_distribution(times)
  221. return {} if times.empty?
  222. # Convert to milliseconds
  223. times_ms = times.map { |t| t * 1000 }
  224. {
  225. under_100ms: times_ms.count { |t| t < 100 },
  226. '100_500ms': times_ms.count { |t| t >= 100 && t < 500 },
  227. '500_1000ms': times_ms.count { |t| t >= 500 && t < 1000 },
  228. over_1000ms: times_ms.count { |t| t >= 1000 }
  229. }
  230. end
  231. def median(array)
  232. return nil if array.empty?
  233. sorted = array.sort
  234. len = sorted.length
  235. (sorted[(len - 1) / 2] + sorted[len / 2]) / 2.0
  236. end
  237. def generate_recommendations
  238. recommendations = []
  239. activities = user_activities
  240. # Check for suspicious activity patterns
  241. if activities.suspicious.count > 5
  242. recommendations << {
  243. type: 'security',
  244. priority: 'high',
  245. message: 'Multiple suspicious activities detected. Review security settings and consider enabling two-factor authentication.'
  246. }
  247. end
  248. # Check for unusual access patterns
  249. night_activities = activities.select { |a| a.occurred_at.hour.between?(0, 5) }
  250. if night_activities.count > activities.count * 0.2
  251. recommendations << {
  252. type: 'security',
  253. priority: 'medium',
  254. message: 'Significant activity during unusual hours detected. Verify these accesses were authorized.'
  255. }
  256. end
  257. # Check for multiple IP addresses
  258. ip_count = activities.distinct.count(:ip_address)
  259. if ip_count > 10
  260. recommendations << {
  261. type: 'security',
  262. priority: 'medium',
  263. message: "Activity from #{ip_count} different IP addresses. Consider reviewing access locations."
  264. }
  265. end
  266. # Performance recommendations
  267. slow_requests = activities.where('response_time > ?', 2.0)
  268. if slow_requests.count > activities.count * 0.1
  269. recommendations << {
  270. type: 'performance',
  271. priority: 'low',
  272. message: 'More than 10% of requests are slow. Consider optimizing frequently accessed pages.'
  273. }
  274. end
  275. recommendations
  276. end
  277. end

app/services/brand_journey_orchestrator.rb

0.0% lines covered

55 relevant lines. 0 lines covered and 55 lines missed.
    
  1. class BrandJourneyOrchestrator
  2. # Simple facade for accessing brand-journey integration features
  3. def self.generate_brand_aware_suggestions(journey:, user: nil, **options)
  4. service = Journey::BrandIntegrationService.new(journey: journey, user: user)
  5. service.orchestrate_brand_journey_flow(operation: :generate_suggestions, **options)
  6. end
  7. def self.validate_journey_brand_compliance(journey:, user: nil, **options)
  8. service = Journey::BrandIntegrationService.new(journey: journey, user: user)
  9. service.orchestrate_brand_journey_flow(operation: :validate_content, **options)
  10. end
  11. def self.enhance_journey_compliance(journey:, user: nil, **options)
  12. service = Journey::BrandIntegrationService.new(journey: journey, user: user)
  13. service.orchestrate_brand_journey_flow(operation: :auto_enhance_compliance, **options)
  14. end
  15. def self.analyze_brand_performance(journey:, user: nil, **options)
  16. service = Journey::BrandIntegrationService.new(journey: journey, user: user)
  17. service.orchestrate_brand_journey_flow(operation: :analyze_brand_performance, **options)
  18. end
  19. def self.sync_with_brand_updates(journey:, user: nil, **options)
  20. service = Journey::BrandIntegrationService.new(journey: journey, user: user)
  21. service.orchestrate_brand_journey_flow(operation: :sync_brand_updates, **options)
  22. end
  23. def self.check_integration_health(journey:, user: nil)
  24. service = Journey::BrandIntegrationService.new(journey: journey, user: user)
  25. service.integration_health_check
  26. end
  27. # Convenience methods for common operations
  28. def self.quick_compliance_check(journey:)
  29. return { score: 1.0, message: 'No brand associated' } unless journey.brand.present?
  30. scores = journey.journey_steps.map(&:quick_compliance_score)
  31. average_score = scores.sum / scores.length
  32. {
  33. score: average_score.round(3),
  34. compliant_steps: scores.count { |s| s >= 0.7 },
  35. total_steps: scores.length,
  36. compliance_rate: (scores.count { |s| s >= 0.7 }.to_f / scores.length * 100).round(1)
  37. }
  38. end
  39. def self.brand_integration_status(journey:)
  40. return { integrated: false, reason: 'No brand associated' } unless journey.brand.present?
  41. brand = journey.brand
  42. integration_indicators = {
  43. has_messaging_framework: brand.messaging_framework.present?,
  44. has_active_guidelines: brand.brand_guidelines.active.any?,
  45. has_voice_attributes: brand.brand_voice_attributes.present?,
  46. recent_compliance_checks: journey.journey_insights.brand_compliance.recent(7).any?
  47. }
  48. integration_score = integration_indicators.values.count(true).to_f / integration_indicators.length
  49. {
  50. integrated: integration_score >= 0.5,
  51. integration_score: integration_score.round(2),
  52. indicators: integration_indicators,
  53. status: integration_score >= 0.8 ? 'fully_integrated' :
  54. integration_score >= 0.5 ? 'partially_integrated' : 'not_integrated'
  55. }
  56. end
  57. end

app/services/branding/analysis_service.rb

0.0% lines covered

1940 relevant lines. 0 lines covered and 1940 lines missed.
    
  1. module Branding
  2. class AnalysisService
  3. attr_reader :brand, :content, :options, :visual_assets
  4. # Constants for analysis configuration
  5. MAX_CONTENT_LENGTH = 50_000
  6. CHUNK_SIZE = 4_000
  7. MIN_CONTENT_LENGTH = 100
  8. DEFAULT_CONFIDENCE_THRESHOLD = 0.7
  9. # Analysis categories
  10. VOICE_DIMENSIONS = {
  11. formality: %w[very_formal formal neutral casual very_casual],
  12. energy: %w[high_energy energetic balanced calm subdued],
  13. warmth: %w[very_warm warm neutral cool professional],
  14. authority: %w[commanding authoritative balanced approachable peer_level]
  15. }.freeze
  16. TONE_ATTRIBUTES = %w[
  17. professional friendly authoritative conversational playful
  18. serious inspiring educational empathetic bold innovative
  19. trustworthy approachable technical sophisticated
  20. ].freeze
  21. WRITING_STYLES = %w[
  22. descriptive concise technical storytelling analytical
  23. persuasive informative instructional narrative expository
  24. ].freeze
  25. def initialize(brand, content = nil, options = {})
  26. @brand = brand
  27. @options = options
  28. @content = content || aggregate_brand_content
  29. @visual_assets = brand.brand_assets.where(asset_type: ['logo', 'image', 'visual'])
  30. @llm_provider = options[:llm_provider] || determine_best_provider
  31. end
  32. def analyze
  33. return { success: false, error: "Insufficient content for analysis" } if content.blank? || content.length < MIN_CONTENT_LENGTH
  34. analysis = brand.brand_analyses.create!(
  35. analysis_status: "processing",
  36. analysis_data: { started_at: Time.current }
  37. )
  38. BrandAnalysisJob.perform_later(analysis.id)
  39. { success: true, analysis_id: analysis.id }
  40. rescue StandardError => e
  41. Rails.logger.error "Brand analysis error: #{e.message}\n#{e.backtrace.join("\n")}"
  42. { success: false, error: e.message }
  43. end
  44. def perform_analysis(analysis)
  45. analysis.mark_as_processing!
  46. begin
  47. # Multi-stage analysis with chunking for large content
  48. content_chunks = chunk_content(@content)
  49. # Stage 1: Voice and tone analysis across all chunks
  50. voice_attrs = analyze_voice_and_tone_comprehensive(content_chunks)
  51. # Stage 2: Brand values extraction with context
  52. brand_vals = extract_brand_values_with_context(content_chunks)
  53. # Stage 3: Messaging pillars with examples
  54. messaging_pillars = extract_messaging_pillars_detailed(content_chunks)
  55. # Stage 4: Comprehensive guidelines extraction
  56. guidelines = extract_guidelines_comprehensive(content_chunks)
  57. # Stage 5: Visual brand analysis (if applicable)
  58. visual_guide = analyze_visual_brand_elements
  59. # Stage 6: Cross-reference and validate findings
  60. validated_data = cross_validate_findings(
  61. voice_attrs, brand_vals, messaging_pillars, guidelines
  62. )
  63. # Stage 7: Calculate comprehensive confidence score
  64. confidence = calculate_comprehensive_confidence_score(validated_data)
  65. # Update analysis with all findings
  66. analysis.update!(
  67. voice_attributes: validated_data[:voice_attributes],
  68. brand_values: validated_data[:brand_values],
  69. messaging_pillars: validated_data[:messaging_pillars],
  70. extracted_rules: validated_data[:guidelines],
  71. visual_guidelines: visual_guide,
  72. confidence_score: confidence[:overall],
  73. analysis_data: analysis.analysis_data.merge(
  74. confidence_breakdown: confidence[:breakdown],
  75. analysis_metadata: {
  76. content_length: @content.length,
  77. chunks_analyzed: content_chunks.size,
  78. visual_assets_analyzed: @visual_assets.count,
  79. llm_provider: @llm_provider,
  80. completed_at: Time.current
  81. }
  82. ),
  83. analysis_status: "completed",
  84. analyzed_at: Time.current
  85. )
  86. # Create actionable guidelines and frameworks
  87. create_comprehensive_guidelines(analysis)
  88. update_messaging_framework_detailed(analysis)
  89. generate_brand_consistency_report(analysis)
  90. true
  91. rescue StandardError => e
  92. Rails.logger.error "Analysis processing error: #{e.message}\n#{e.backtrace.join("\n")}"
  93. analysis.mark_as_failed!("Analysis failed: #{e.message}")
  94. false
  95. end
  96. end
  97. private
  98. def aggregate_brand_content
  99. # Prioritize content by type and recency
  100. content_sources = []
  101. # Priority 1: Brand guidelines and style guides
  102. guidelines_content = brand.brand_assets
  103. .where(asset_type: ['style_guide', 'brand_guidelines', 'voice_guide'])
  104. .processed
  105. .pluck(:extracted_text, :metadata)
  106. content_sources.concat(
  107. guidelines_content.map { |text, meta|
  108. { content: text, priority: 1, source: meta['filename'] || 'Brand Guidelines' }
  109. }
  110. )
  111. # Priority 2: Marketing materials and messaging docs
  112. marketing_content = brand.brand_assets
  113. .where(asset_type: ['marketing_material', 'messaging_doc', 'presentation'])
  114. .processed
  115. .pluck(:extracted_text, :metadata)
  116. content_sources.concat(
  117. marketing_content.map { |text, meta|
  118. { content: text, priority: 2, source: meta['filename'] || 'Marketing Material' }
  119. }
  120. )
  121. # Priority 3: Website content and other materials
  122. other_content = brand.brand_assets
  123. .where.not(asset_type: ['style_guide', 'brand_guidelines', 'voice_guide',
  124. 'marketing_material', 'messaging_doc', 'presentation',
  125. 'logo', 'image', 'visual'])
  126. .processed
  127. .pluck(:extracted_text, :metadata)
  128. content_sources.concat(
  129. other_content.map { |text, meta|
  130. { content: text, priority: 3, source: meta['filename'] || 'Other Content' }
  131. }
  132. )
  133. # Sort by priority and combine
  134. @content_sources = content_sources.sort_by { |s| s[:priority] }
  135. # Combine with priority weighting
  136. combined_content = @content_sources.map { |source|
  137. "\n\n[Source: #{source[:source]}]\n#{source[:content]}"
  138. }.join("\n\n")
  139. # Truncate if too long
  140. combined_content.truncate(MAX_CONTENT_LENGTH)
  141. end
  142. def chunk_content(content)
  143. return [content] if content.length <= CHUNK_SIZE
  144. chunks = []
  145. sentences = content.split(/(?<=[.!?])\s+/)
  146. current_chunk = ""
  147. sentences.each do |sentence|
  148. if (current_chunk.length + sentence.length) > CHUNK_SIZE && current_chunk.present?
  149. chunks << current_chunk.strip
  150. current_chunk = sentence
  151. else
  152. current_chunk += " #{sentence}"
  153. end
  154. end
  155. chunks << current_chunk.strip if current_chunk.present?
  156. chunks
  157. end
  158. def determine_best_provider
  159. # Prioritize providers based on capabilities and availability
  160. if ENV['ANTHROPIC_API_KEY'].present?
  161. 'claude-3-opus-20240229' # Best for nuanced brand analysis
  162. elsif ENV['OPENAI_API_KEY'].present?
  163. 'gpt-4-turbo-preview' # Good for structured output
  164. else
  165. 'gpt-3.5-turbo' # Fallback option
  166. end
  167. end
  168. def analyze_voice_and_tone_comprehensive(content_chunks)
  169. # Analyze each chunk for voice consistency
  170. chunk_analyses = content_chunks.map.with_index do |chunk, index|
  171. prompt = build_comprehensive_voice_prompt(chunk, index, content_chunks.size)
  172. response = llm_service.analyze(prompt, json_response: true)
  173. parse_voice_response_safe(response)
  174. end
  175. # Aggregate and reconcile findings
  176. aggregate_voice_attributes(chunk_analyses)
  177. end
  178. def build_comprehensive_voice_prompt(content, chunk_index, total_chunks)
  179. <<~PROMPT
  180. You are an expert brand voice analyst. Analyze this brand content (chunk #{chunk_index + 1} of #{total_chunks}) for voice and tone characteristics.
  181. Content:
  182. #{content}
  183. Provide a detailed analysis in the following JSON structure:
  184. {
  185. "formality": {
  186. "level": "one of: #{VOICE_DIMENSIONS[:formality].join(', ')}",
  187. "score": 0.0-1.0,
  188. "evidence": ["specific phrases showing formality level"],
  189. "consistency": 0.0-1.0
  190. },
  191. "energy": {
  192. "level": "one of: #{VOICE_DIMENSIONS[:energy].join(', ')}",
  193. "score": 0.0-1.0,
  194. "evidence": ["specific phrases showing energy level"]
  195. },
  196. "warmth": {
  197. "level": "one of: #{VOICE_DIMENSIONS[:warmth].join(', ')}",
  198. "score": 0.0-1.0,
  199. "evidence": ["specific phrases showing warmth level"]
  200. },
  201. "authority": {
  202. "level": "one of: #{VOICE_DIMENSIONS[:authority].join(', ')}",
  203. "score": 0.0-1.0,
  204. "evidence": ["specific phrases showing authority level"]
  205. },
  206. "tone": {
  207. "primary": "main tone from: #{TONE_ATTRIBUTES.join(', ')}",
  208. "secondary": ["2-3 secondary tones"],
  209. "avoided": ["tones that are notably absent"],
  210. "consistency": 0.0-1.0
  211. },
  212. "style": {
  213. "writing": "primary style from: #{WRITING_STYLES.join(', ')}",
  214. "sentence_structure": "simple/compound/complex/varied",
  215. "vocabulary": "basic/intermediate/advanced/technical/mixed",
  216. "paragraph_length": "short/medium/long/varied",
  217. "active_passive_ratio": 0.0-1.0
  218. },
  219. "personality_traits": ["5-7 key personality descriptors"],
  220. "linguistic_patterns": {
  221. "common_phrases": ["frequently used phrases"],
  222. "power_words": ["impactful words used"],
  223. "transitions": ["common transition phrases"],
  224. "openings": ["typical sentence/paragraph starters"],
  225. "closings": ["typical ending patterns"]
  226. },
  227. "emotional_tone": {
  228. "primary_emotion": "dominant emotional undertone",
  229. "emotional_range": "narrow/moderate/wide",
  230. "positivity_ratio": 0.0-1.0
  231. }
  232. }
  233. Be specific and cite actual examples from the text. Focus on patterns, not isolated instances.
  234. PROMPT
  235. end
  236. def parse_voice_response_safe(response)
  237. return default_voice_attributes if response.blank?
  238. begin
  239. parsed = JSON.parse(response) rescue response
  240. # Validate and clean the response
  241. {
  242. formality: validate_dimension(parsed['formality'], 'formality'),
  243. energy: validate_dimension(parsed['energy'], 'energy'),
  244. warmth: validate_dimension(parsed['warmth'], 'warmth'),
  245. authority: validate_dimension(parsed['authority'], 'authority'),
  246. tone: validate_tone(parsed['tone']),
  247. style: validate_style(parsed['style']),
  248. personality_traits: Array(parsed['personality_traits']).first(7),
  249. linguistic_patterns: validate_patterns(parsed['linguistic_patterns']),
  250. emotional_tone: validate_emotional_tone(parsed['emotional_tone'])
  251. }
  252. rescue => e
  253. Rails.logger.error "Voice parsing error: #{e.message}"
  254. default_voice_attributes
  255. end
  256. end
  257. def validate_dimension(dimension_data, dimension_name)
  258. return default_dimension(dimension_name) unless dimension_data.is_a?(Hash)
  259. {
  260. level: VOICE_DIMENSIONS[dimension_name.to_sym].include?(dimension_data['level']) ?
  261. dimension_data['level'] : VOICE_DIMENSIONS[dimension_name.to_sym][2],
  262. score: [dimension_data['score'].to_f, 1.0].min,
  263. evidence: Array(dimension_data['evidence']).first(5),
  264. consistency: dimension_data['consistency']&.to_f || 0.7
  265. }
  266. end
  267. def validate_tone(tone_data)
  268. return default_tone unless tone_data.is_a?(Hash)
  269. {
  270. primary: TONE_ATTRIBUTES.include?(tone_data['primary']) ?
  271. tone_data['primary'] : 'professional',
  272. secondary: Array(tone_data['secondary']).select { |t| TONE_ATTRIBUTES.include?(t) }.first(3),
  273. avoided: Array(tone_data['avoided']),
  274. consistency: tone_data['consistency']&.to_f || 0.7
  275. }
  276. end
  277. def validate_style(style_data)
  278. return default_style unless style_data.is_a?(Hash)
  279. {
  280. writing: WRITING_STYLES.include?(style_data['writing']) ?
  281. style_data['writing'] : 'informative',
  282. sentence_structure: style_data['sentence_structure'] || 'varied',
  283. vocabulary: style_data['vocabulary'] || 'intermediate',
  284. paragraph_length: style_data['paragraph_length'] || 'medium',
  285. active_passive_ratio: style_data['active_passive_ratio']&.to_f || 0.8
  286. }
  287. end
  288. def aggregate_voice_attributes(chunk_analyses)
  289. # Remove any failed analyses
  290. valid_analyses = chunk_analyses.reject { |a| a == default_voice_attributes }
  291. return default_voice_attributes if valid_analyses.empty?
  292. # Aggregate each dimension
  293. aggregated = {
  294. formality: aggregate_dimension(valid_analyses, :formality),
  295. energy: aggregate_dimension(valid_analyses, :energy),
  296. warmth: aggregate_dimension(valid_analyses, :warmth),
  297. authority: aggregate_dimension(valid_analyses, :authority),
  298. tone: aggregate_tone(valid_analyses),
  299. style: aggregate_style(valid_analyses),
  300. personality_traits: aggregate_personality_traits(valid_analyses),
  301. linguistic_patterns: aggregate_patterns(valid_analyses),
  302. emotional_tone: aggregate_emotional_tone(valid_analyses),
  303. consistency_score: calculate_voice_consistency(valid_analyses)
  304. }
  305. aggregated
  306. end
  307. def aggregate_dimension(analyses, dimension)
  308. dimensions = analyses.map { |a| a[dimension] }.compact
  309. # Count frequency of each level
  310. level_counts = dimensions.group_by { |d| d[:level] }
  311. .transform_values(&:count)
  312. # Most common level
  313. primary_level = level_counts.max_by { |_, count| count }&.first
  314. # Average score
  315. avg_score = dimensions.map { |d| d[:score] }.sum.to_f / dimensions.size
  316. # Collect all evidence
  317. all_evidence = dimensions.flat_map { |d| d[:evidence] || [] }.uniq.first(10)
  318. # Calculate consistency across chunks
  319. consistency = calculate_dimension_consistency(dimensions)
  320. {
  321. level: primary_level,
  322. score: avg_score.round(2),
  323. evidence: all_evidence,
  324. consistency: consistency,
  325. distribution: level_counts
  326. }
  327. end
  328. def extract_brand_values_with_context(content_chunks)
  329. # Extract values from each chunk with context
  330. chunk_values = content_chunks.map.with_index do |chunk, index|
  331. prompt = build_brand_values_extraction_prompt(chunk, index, content_chunks.size)
  332. response = llm_service.analyze(prompt, json_response: true)
  333. parse_brand_values_response(response)
  334. end
  335. # Aggregate and rank by frequency and importance
  336. aggregate_brand_values(chunk_values)
  337. end
  338. def build_brand_values_extraction_prompt(content, chunk_index, total_chunks)
  339. <<~PROMPT
  340. You are an expert brand strategist analyzing brand values. Examine this content (chunk #{chunk_index + 1} of #{total_chunks}) to identify core brand values.
  341. Content:
  342. #{content}
  343. Identify brand values using this comprehensive approach:
  344. 1. EXPLICIT VALUES: Look for directly stated values, mission statements, or "what we believe" sections
  345. 2. IMPLIED VALUES: Infer values from:
  346. - Repeated themes and concepts
  347. - The way products/services are described
  348. - How the brand talks about customers
  349. - What the brand emphasizes or prioritizes
  350. - Language choices and framing
  351. 3. BEHAVIORAL VALUES: Values demonstrated through:
  352. - Actions described
  353. - Commitments made
  354. - Problems the brand chooses to solve
  355. - How the brand differentiates itself
  356. Return a JSON response with this structure:
  357. {
  358. "explicit_values": [
  359. {
  360. "value": "Innovation",
  361. "evidence": "Direct quote or reference",
  362. "context": "Where/how it was mentioned",
  363. "strength": 0.0-1.0
  364. }
  365. ],
  366. "implied_values": [
  367. {
  368. "value": "Customer-centricity",
  369. "evidence": "Patterns or themes observed",
  370. "reasoning": "Why this value is implied",
  371. "strength": 0.0-1.0
  372. }
  373. ],
  374. "behavioral_values": [
  375. {
  376. "value": "Sustainability",
  377. "evidence": "Actions or commitments described",
  378. "manifestation": "How it's demonstrated",
  379. "strength": 0.0-1.0
  380. }
  381. ],
  382. "value_hierarchy": [
  383. "Ordered list of values by importance based on emphasis"
  384. ],
  385. "conflicting_values": [
  386. {
  387. "value1": "Speed",
  388. "value2": "Perfection",
  389. "explanation": "How these might conflict"
  390. }
  391. ]
  392. }
  393. Focus on identifying 3-7 core values that truly define this brand. Be specific and cite evidence.
  394. PROMPT
  395. end
  396. def parse_brand_values_response(response)
  397. return default_brand_values_structure if response.blank?
  398. begin
  399. parsed = JSON.parse(response) rescue response
  400. {
  401. explicit_values: parse_value_list(parsed['explicit_values']),
  402. implied_values: parse_value_list(parsed['implied_values']),
  403. behavioral_values: parse_value_list(parsed['behavioral_values']),
  404. value_hierarchy: Array(parsed['value_hierarchy']).first(7),
  405. conflicting_values: Array(parsed['conflicting_values'])
  406. }
  407. rescue => e
  408. Rails.logger.error "Brand values parsing error: #{e.message}"
  409. default_brand_values_structure
  410. end
  411. end
  412. def parse_value_list(values)
  413. return [] unless values.is_a?(Array)
  414. values.map do |value_data|
  415. next unless value_data.is_a?(Hash)
  416. {
  417. value: value_data['value'],
  418. evidence: value_data['evidence'],
  419. context: value_data['context'] || value_data['reasoning'] || value_data['manifestation'],
  420. strength: [value_data['strength'].to_f, 1.0].min
  421. }
  422. end.compact
  423. end
  424. def aggregate_brand_values(chunk_values)
  425. all_values = {
  426. explicit: [],
  427. implied: [],
  428. behavioral: []
  429. }
  430. # Collect all values across chunks
  431. chunk_values.each do |chunk|
  432. all_values[:explicit].concat(chunk[:explicit_values] || [])
  433. all_values[:implied].concat(chunk[:implied_values] || [])
  434. all_values[:behavioral].concat(chunk[:behavioral_values] || [])
  435. end
  436. # Group by value name and aggregate
  437. aggregated_values = {}
  438. [:explicit, :implied, :behavioral].each do |type|
  439. all_values[type].group_by { |v| v[:value]&.downcase }
  440. .each do |value_name, instances|
  441. next if value_name.blank?
  442. aggregated_values[value_name] ||= {
  443. value: instances.first[:value], # Original case
  444. type: type,
  445. frequency: 0,
  446. total_strength: 0,
  447. evidence: [],
  448. contexts: []
  449. }
  450. aggregated_values[value_name][:frequency] += instances.size
  451. aggregated_values[value_name][:total_strength] += instances.sum { |i| i[:strength] }
  452. aggregated_values[value_name][:evidence].concat(instances.map { |i| i[:evidence] }.compact)
  453. aggregated_values[value_name][:contexts].concat(instances.map { |i| i[:context] }.compact)
  454. end
  455. end
  456. # Calculate final scores and rank
  457. final_values = aggregated_values.values.map do |value_data|
  458. avg_strength = value_data[:total_strength] / value_data[:frequency]
  459. # Boost score for explicit values and frequency
  460. type_weight = case value_data[:type]
  461. when :explicit then 1.2
  462. when :behavioral then 1.1
  463. else 1.0
  464. end
  465. frequency_weight = Math.log(value_data[:frequency] + 1) / Math.log(chunk_values.size + 1)
  466. final_score = (avg_strength * type_weight * (0.7 + 0.3 * frequency_weight))
  467. {
  468. name: value_data[:value],
  469. score: final_score.round(3),
  470. type: value_data[:type],
  471. frequency: value_data[:frequency],
  472. evidence: value_data[:evidence].uniq.first(5),
  473. contexts: value_data[:contexts].uniq.first(3)
  474. }
  475. end
  476. # Sort by score and take top values
  477. final_values.sort_by { |v| -v[:score] }.first(7)
  478. end
  479. def default_brand_values_structure
  480. {
  481. explicit_values: [],
  482. implied_values: [],
  483. behavioral_values: [],
  484. value_hierarchy: [],
  485. conflicting_values: []
  486. }
  487. end
  488. def extract_messaging_pillars_detailed(content_chunks)
  489. # Extract pillars from each chunk
  490. chunk_pillars = content_chunks.map.with_index do |chunk, index|
  491. prompt = build_messaging_pillars_extraction_prompt(chunk, index, content_chunks.size)
  492. response = llm_service.analyze(prompt, json_response: true)
  493. parse_messaging_pillars_response(response)
  494. end
  495. # Aggregate and structure pillars
  496. aggregate_messaging_pillars(chunk_pillars)
  497. end
  498. def build_messaging_pillars_extraction_prompt(content, chunk_index, total_chunks)
  499. <<~PROMPT
  500. You are an expert messaging strategist. Analyze this brand content (chunk #{chunk_index + 1} of #{total_chunks}) to identify key messaging pillars.
  501. Content:
  502. #{content}
  503. Identify messaging pillars - the core themes that support all brand communications. Look for:
  504. 1. RECURRING THEMES: Topics or concepts that appear multiple times
  505. 2. VALUE PROPOSITIONS: Key benefits or advantages emphasized
  506. 3. DIFFERENTIATORS: What makes this brand unique
  507. 4. AUDIENCE BENEFITS: How the brand helps its customers
  508. 5. PROOF POINTS: Evidence, features, or capabilities that support claims
  509. Return a JSON response with this structure:
  510. {
  511. "pillars": [
  512. {
  513. "name": "Clear, descriptive pillar name",
  514. "description": "What this pillar represents",
  515. "key_messages": [
  516. "Specific messages under this pillar"
  517. ],
  518. "supporting_points": [
  519. "Facts, features, or benefits that support this pillar"
  520. ],
  521. "target_emotion": "What feeling this pillar aims to evoke",
  522. "evidence": [
  523. "Quotes or references from the content"
  524. ],
  525. "frequency": 1-10,
  526. "importance": 1-10
  527. }
  528. ],
  529. "pillar_relationships": [
  530. {
  531. "pillar1": "Name of first pillar",
  532. "pillar2": "Name of second pillar",
  533. "relationship": "How these pillars connect or support each other"
  534. }
  535. ],
  536. "missing_pillars": [
  537. {
  538. "suggested_pillar": "What might be missing",
  539. "rationale": "Why this could strengthen the messaging"
  540. }
  541. ]
  542. }
  543. Identify 3-5 main pillars that form the foundation of this brand's messaging.
  544. PROMPT
  545. end
  546. def parse_messaging_pillars_response(response)
  547. return default_pillars_structure if response.blank?
  548. begin
  549. parsed = JSON.parse(response) rescue response
  550. {
  551. pillars: parse_pillars_list(parsed['pillars']),
  552. relationships: Array(parsed['pillar_relationships']),
  553. missing: Array(parsed['missing_pillars'])
  554. }
  555. rescue => e
  556. Rails.logger.error "Messaging pillars parsing error: #{e.message}"
  557. default_pillars_structure
  558. end
  559. end
  560. def parse_pillars_list(pillars)
  561. return [] unless pillars.is_a?(Array)
  562. pillars.map do |pillar|
  563. next unless pillar.is_a?(Hash)
  564. {
  565. name: pillar['name'],
  566. description: pillar['description'],
  567. key_messages: Array(pillar['key_messages']).first(5),
  568. supporting_points: Array(pillar['supporting_points']).first(5),
  569. target_emotion: pillar['target_emotion'],
  570. evidence: Array(pillar['evidence']).first(3),
  571. frequency: [pillar['frequency'].to_i, 10].min,
  572. importance: [pillar['importance'].to_i, 10].min
  573. }
  574. end.compact
  575. end
  576. def aggregate_messaging_pillars(chunk_pillars)
  577. all_pillars = {}
  578. all_relationships = []
  579. # Collect all pillars
  580. chunk_pillars.each do |chunk|
  581. chunk[:pillars].each do |pillar|
  582. key = pillar[:name]&.downcase&.strip
  583. next if key.blank?
  584. all_pillars[key] ||= {
  585. name: pillar[:name],
  586. description: [],
  587. key_messages: [],
  588. supporting_points: [],
  589. target_emotions: [],
  590. evidence: [],
  591. total_frequency: 0,
  592. total_importance: 0,
  593. occurrences: 0
  594. }
  595. all_pillars[key][:description] << pillar[:description]
  596. all_pillars[key][:key_messages].concat(pillar[:key_messages] || [])
  597. all_pillars[key][:supporting_points].concat(pillar[:supporting_points] || [])
  598. all_pillars[key][:target_emotions] << pillar[:target_emotion]
  599. all_pillars[key][:evidence].concat(pillar[:evidence] || [])
  600. all_pillars[key][:total_frequency] += pillar[:frequency]
  601. all_pillars[key][:total_importance] += pillar[:importance]
  602. all_pillars[key][:occurrences] += 1
  603. end
  604. all_relationships.concat(chunk[:relationships] || [])
  605. end
  606. # Process and rank pillars
  607. processed_pillars = all_pillars.map do |key, data|
  608. avg_frequency = data[:total_frequency].to_f / data[:occurrences]
  609. avg_importance = data[:total_importance].to_f / data[:occurrences]
  610. occurrence_weight = Math.log(data[:occurrences] + 1) / Math.log(chunk_pillars.size + 1)
  611. score = (avg_frequency * 0.3 + avg_importance * 0.5 + occurrence_weight * 10 * 0.2)
  612. {
  613. name: data[:name],
  614. description: most_representative(data[:description]),
  615. key_messages: deduplicate_and_rank(data[:key_messages], 5),
  616. supporting_points: deduplicate_and_rank(data[:supporting_points], 7),
  617. target_emotion: most_common(data[:target_emotions].compact),
  618. evidence: data[:evidence].uniq.first(5),
  619. strength_score: score.round(2),
  620. consistency_score: (data[:occurrences].to_f / chunk_pillars.size).round(2)
  621. }
  622. end
  623. # Sort by score and take top pillars
  624. top_pillars = processed_pillars.sort_by { |p| -p[:strength_score] }.first(5)
  625. # Process relationships for top pillars
  626. pillar_names = top_pillars.map { |p| p[:name].downcase }
  627. relevant_relationships = all_relationships.select do |rel|
  628. pillar_names.include?(rel['pillar1']&.downcase) &&
  629. pillar_names.include?(rel['pillar2']&.downcase)
  630. end.uniq
  631. {
  632. pillars: top_pillars,
  633. relationships: relevant_relationships,
  634. pillar_hierarchy: create_pillar_hierarchy(top_pillars, relevant_relationships)
  635. }
  636. end
  637. def most_representative(descriptions)
  638. # Find the most complete/representative description
  639. descriptions.compact.max_by(&:length) || ""
  640. end
  641. def deduplicate_and_rank(items, limit)
  642. # Remove duplicates and rank by frequency
  643. items.group_by { |item| item.downcase.strip }
  644. .sort_by { |_, instances| -instances.size }
  645. .first(limit)
  646. .map { |_, instances| instances.first }
  647. end
  648. def create_pillar_hierarchy(pillars, relationships)
  649. # Create a simple hierarchy based on scores and relationships
  650. {
  651. primary: pillars.first(2).map { |p| p[:name] },
  652. supporting: pillars[2..-1]&.map { |p| p[:name] } || [],
  653. connections: relationships.map { |r|
  654. "#{r['pillar1']} + #{r['pillar2']}: #{r['relationship']}"
  655. }
  656. }
  657. end
  658. def default_pillars_structure
  659. {
  660. pillars: [],
  661. relationships: [],
  662. missing: []
  663. }
  664. end
  665. def extract_guidelines_comprehensive(content_chunks)
  666. # Extract guidelines from each chunk with categorization
  667. chunk_guidelines = content_chunks.map.with_index do |chunk, index|
  668. prompt = build_comprehensive_guidelines_prompt(chunk, index, content_chunks.size)
  669. response = llm_service.analyze(prompt, json_response: true)
  670. parse_guidelines_response(response)
  671. end
  672. # Aggregate and categorize guidelines
  673. aggregate_guidelines(chunk_guidelines)
  674. end
  675. def build_comprehensive_guidelines_prompt(content, chunk_index, total_chunks)
  676. <<~PROMPT
  677. You are an expert brand guidelines analyst. Extract all brand rules, guidelines, and requirements from this content (chunk #{chunk_index + 1} of #{total_chunks}).
  678. Content:
  679. #{content}
  680. Extract guidelines in these categories:
  681. 1. VOICE & TONE RULES:
  682. - How to speak/write
  683. - Tone requirements
  684. - Voice characteristics to maintain
  685. - Language do's and don'ts
  686. 2. MESSAGING RULES:
  687. - What to communicate
  688. - Key messages to include
  689. - Topics to avoid
  690. - Claims restrictions
  691. 3. VISUAL RULES:
  692. - Color usage
  693. - Typography requirements
  694. - Logo usage
  695. - Image style
  696. 4. GRAMMAR & STYLE:
  697. - Punctuation rules
  698. - Capitalization
  699. - Formatting requirements
  700. - Writing conventions
  701. 5. BRAND BEHAVIOR:
  702. - How the brand should act
  703. - Customer interaction guidelines
  704. - Response patterns
  705. - Ethics and values in practice
  706. Return a JSON response with this structure:
  707. {
  708. "voice_tone_rules": {
  709. "must_do": ["Required voice/tone elements"],
  710. "should_do": ["Recommended practices"],
  711. "must_not_do": ["Prohibited voice/tone elements"],
  712. "examples": {
  713. "good": ["Examples of correct usage"],
  714. "bad": ["Examples to avoid"]
  715. }
  716. },
  717. "messaging_rules": {
  718. "required_elements": ["Must-include messages"],
  719. "key_phrases": ["Specific phrases to use"],
  720. "prohibited_topics": ["Topics/claims to avoid"],
  721. "competitor_mentions": "Guidelines for mentioning competitors"
  722. },
  723. "visual_rules": {
  724. "colors": {
  725. "primary": ["#hex codes"],
  726. "secondary": ["#hex codes"],
  727. "usage_rules": ["When/how to use colors"]
  728. },
  729. "typography": {
  730. "fonts": ["Font names and weights"],
  731. "sizes": ["Size specifications"],
  732. "usage_rules": ["When to use which fonts"]
  733. },
  734. "imagery": {
  735. "style": "Description of image style",
  736. "do": ["Image requirements"],
  737. "dont": ["Image restrictions"]
  738. }
  739. },
  740. "grammar_style_rules": {
  741. "punctuation": ["Specific punctuation rules"],
  742. "capitalization": ["What to capitalize"],
  743. "formatting": ["Format requirements"],
  744. "preferred_terms": {"use_this": "not_that"}
  745. },
  746. "behavioral_rules": {
  747. "customer_interaction": ["How to interact with customers"],
  748. "response_patterns": ["How to respond to situations"],
  749. "ethical_guidelines": ["Ethical considerations"]
  750. },
  751. "rule_priority": [
  752. {
  753. "rule": "Most important rule",
  754. "category": "Which category",
  755. "importance": 1-10,
  756. "consequences": "What happens if violated"
  757. }
  758. ]
  759. }
  760. Be specific and extract actual rules, not general observations.
  761. PROMPT
  762. end
  763. def parse_guidelines_response(response)
  764. return default_guidelines_structure if response.blank?
  765. begin
  766. parsed = JSON.parse(response) rescue response
  767. {
  768. voice_tone_rules: parse_rule_category(parsed['voice_tone_rules']),
  769. messaging_rules: parse_rule_category(parsed['messaging_rules']),
  770. visual_rules: parse_visual_rules(parsed['visual_rules']),
  771. grammar_style_rules: parse_rule_category(parsed['grammar_style_rules']),
  772. behavioral_rules: parse_rule_category(parsed['behavioral_rules']),
  773. rule_priority: parse_rule_priorities(parsed['rule_priority'])
  774. }
  775. rescue => e
  776. Rails.logger.error "Guidelines parsing error: #{e.message}"
  777. default_guidelines_structure
  778. end
  779. end
  780. def parse_rule_category(category_data)
  781. return {} unless category_data.is_a?(Hash)
  782. category_data.transform_values do |value|
  783. case value
  784. when Array then value.first(10)
  785. when Hash then value
  786. when String then value
  787. else []
  788. end
  789. end
  790. end
  791. def parse_visual_rules(visual_data)
  792. return {} unless visual_data.is_a?(Hash)
  793. {
  794. colors: parse_color_rules(visual_data['colors']),
  795. typography: parse_typography_rules(visual_data['typography']),
  796. imagery: parse_imagery_rules(visual_data['imagery'])
  797. }
  798. end
  799. def parse_color_rules(color_data)
  800. return {} unless color_data.is_a?(Hash)
  801. {
  802. primary: Array(color_data['primary']).select { |c| c =~ /^#[0-9A-Fa-f]{6}$/ },
  803. secondary: Array(color_data['secondary']).select { |c| c =~ /^#[0-9A-Fa-f]{6}$/ },
  804. usage_rules: Array(color_data['usage_rules'])
  805. }
  806. end
  807. def parse_typography_rules(typography_data)
  808. return {} unless typography_data.is_a?(Hash)
  809. {
  810. fonts: Array(typography_data['fonts']),
  811. sizes: Array(typography_data['sizes']),
  812. usage_rules: Array(typography_data['usage_rules'])
  813. }
  814. end
  815. def parse_imagery_rules(imagery_data)
  816. return {} unless imagery_data.is_a?(Hash)
  817. {
  818. style: imagery_data['style'] || '',
  819. do: Array(imagery_data['do']),
  820. dont: Array(imagery_data['dont'])
  821. }
  822. end
  823. def parse_rule_priorities(priorities)
  824. return [] unless priorities.is_a?(Array)
  825. priorities.map do |priority|
  826. next unless priority.is_a?(Hash)
  827. {
  828. rule: priority['rule'],
  829. category: priority['category'],
  830. importance: [priority['importance'].to_i, 10].min,
  831. consequences: priority['consequences']
  832. }
  833. end.compact.first(10)
  834. end
  835. def aggregate_guidelines(chunk_guidelines)
  836. aggregated = {
  837. voice_tone_rules: aggregate_rule_category(chunk_guidelines, :voice_tone_rules),
  838. messaging_rules: aggregate_rule_category(chunk_guidelines, :messaging_rules),
  839. visual_rules: aggregate_visual_rules(chunk_guidelines),
  840. grammar_style_rules: aggregate_rule_category(chunk_guidelines, :grammar_style_rules),
  841. behavioral_rules: aggregate_rule_category(chunk_guidelines, :behavioral_rules),
  842. rule_priorities: aggregate_priorities(chunk_guidelines),
  843. rule_consistency: calculate_rule_consistency(chunk_guidelines)
  844. }
  845. # Detect and resolve conflicts
  846. aggregated[:conflicts] = detect_rule_conflicts(aggregated)
  847. aggregated
  848. end
  849. def aggregate_rule_category(guidelines, category)
  850. all_rules = {
  851. must_do: [],
  852. should_do: [],
  853. must_not_do: [],
  854. examples: { good: [], bad: [] }
  855. }
  856. guidelines.each do |chunk|
  857. category_data = chunk[category] || {}
  858. all_rules[:must_do].concat(Array(category_data['must_do']))
  859. all_rules[:should_do].concat(Array(category_data['should_do']))
  860. all_rules[:must_not_do].concat(Array(category_data['must_not_do']))
  861. if category_data['examples'].is_a?(Hash)
  862. all_rules[:examples][:good].concat(Array(category_data['examples']['good']))
  863. all_rules[:examples][:bad].concat(Array(category_data['examples']['bad']))
  864. end
  865. end
  866. # Deduplicate and prioritize
  867. {
  868. must_do: deduplicate_rules(all_rules[:must_do]),
  869. should_do: deduplicate_rules(all_rules[:should_do]),
  870. must_not_do: deduplicate_rules(all_rules[:must_not_do]),
  871. examples: {
  872. good: all_rules[:examples][:good].uniq.first(5),
  873. bad: all_rules[:examples][:bad].uniq.first(5)
  874. }
  875. }
  876. end
  877. def deduplicate_rules(rules)
  878. # Group similar rules and take the most detailed version
  879. rules.group_by { |rule| rule.downcase.split.first(3).join(' ') }
  880. .map { |_, group| group.max_by(&:length) }
  881. .uniq
  882. .first(15)
  883. end
  884. def aggregate_visual_rules(guidelines)
  885. all_colors = { primary: [], secondary: [] }
  886. all_fonts = []
  887. all_imagery = { style: [], do: [], dont: [] }
  888. guidelines.each do |chunk|
  889. visual = chunk[:visual_rules] || {}
  890. if visual[:colors]
  891. all_colors[:primary].concat(visual[:colors][:primary] || [])
  892. all_colors[:secondary].concat(visual[:colors][:secondary] || [])
  893. end
  894. if visual[:typography]
  895. all_fonts.concat(visual[:typography][:fonts] || [])
  896. end
  897. if visual[:imagery]
  898. all_imagery[:style] << visual[:imagery][:style] if visual[:imagery][:style].present?
  899. all_imagery[:do].concat(visual[:imagery][:do] || [])
  900. all_imagery[:dont].concat(visual[:imagery][:dont] || [])
  901. end
  902. end
  903. {
  904. colors: {
  905. primary: all_colors[:primary].uniq,
  906. secondary: all_colors[:secondary].uniq
  907. },
  908. typography: {
  909. fonts: all_fonts.uniq
  910. },
  911. imagery: {
  912. style: all_imagery[:style].join('; '),
  913. do: all_imagery[:do].uniq.first(10),
  914. dont: all_imagery[:dont].uniq.first(10)
  915. }
  916. }
  917. end
  918. def aggregate_priorities(guidelines)
  919. all_priorities = guidelines.flat_map { |g| g[:rule_priorities] || [] }
  920. # Group by rule and average importance
  921. grouped = all_priorities.group_by { |p| p[:rule]&.downcase }
  922. priorities = grouped.map do |rule, instances|
  923. avg_importance = instances.map { |i| i[:importance] }.sum.to_f / instances.size
  924. {
  925. rule: instances.first[:rule],
  926. category: most_common(instances.map { |i| i[:category] }),
  927. importance: avg_importance.round,
  928. consequences: instances.first[:consequences],
  929. frequency: instances.size
  930. }
  931. end
  932. priorities.sort_by { |p| [-p[:importance], -p[:frequency]] }.first(20)
  933. end
  934. def calculate_rule_consistency(guidelines)
  935. # Measure how consistent rules are across chunks
  936. return 1.0 if guidelines.size <= 1
  937. rule_categories = [:voice_tone_rules, :messaging_rules, :grammar_style_rules]
  938. consistency_scores = []
  939. rule_categories.each do |category|
  940. all_must_rules = guidelines.map { |g|
  941. (g[category][:must_do] || []).map(&:downcase)
  942. }
  943. if all_must_rules.flatten.any?
  944. # Check overlap between chunks
  945. common_rules = all_must_rules.reduce(:&) || []
  946. total_unique = all_must_rules.flatten.uniq.size
  947. consistency = common_rules.size.to_f / total_unique
  948. consistency_scores << consistency
  949. end
  950. end
  951. consistency_scores.empty? ? 0.5 : (consistency_scores.sum / consistency_scores.size).round(2)
  952. end
  953. def detect_rule_conflicts(aggregated)
  954. conflicts = []
  955. # Check for contradictions between must_do and must_not_do
  956. [:voice_tone_rules, :messaging_rules, :behavioral_rules].each do |category|
  957. must_do = aggregated[category][:must_do] || []
  958. must_not = aggregated[category][:must_not_do] || []
  959. must_do.each do |do_rule|
  960. must_not.each do |dont_rule|
  961. if rules_conflict?(do_rule, dont_rule)
  962. conflicts << {
  963. category: category,
  964. rule1: do_rule,
  965. rule2: dont_rule,
  966. type: 'direct_contradiction'
  967. }
  968. end
  969. end
  970. end
  971. end
  972. conflicts
  973. end
  974. def rules_conflict?(rule1, rule2)
  975. # Simple conflict detection - can be made more sophisticated
  976. keywords1 = rule1.downcase.split(/\W+/)
  977. keywords2 = rule2.downcase.split(/\W+/)
  978. # Check for opposite actions on same subject
  979. common_keywords = keywords1 & keywords2
  980. common_keywords.size > 2
  981. end
  982. def default_guidelines_structure
  983. {
  984. voice_tone_rules: {},
  985. messaging_rules: {},
  986. visual_rules: {},
  987. grammar_style_rules: {},
  988. behavioral_rules: {},
  989. rule_priority: []
  990. }
  991. end
  992. def analyze_visual_brand_elements
  993. return {} if @visual_assets.empty?
  994. visual_analysis = {
  995. colors: extract_colors_from_assets,
  996. typography: extract_typography_from_assets,
  997. imagery: analyze_imagery_style,
  998. logo_usage: analyze_logo_usage,
  999. visual_consistency: calculate_visual_consistency
  1000. }
  1001. # If we have style guides, enhance with explicit rules
  1002. style_guides = @visual_assets.where(asset_type: 'style_guide')
  1003. if style_guides.any?
  1004. enhance_visual_analysis_with_guides(visual_analysis, style_guides)
  1005. end
  1006. visual_analysis
  1007. end
  1008. def extract_colors_from_assets
  1009. colors = {
  1010. primary: [],
  1011. secondary: [],
  1012. accent: [],
  1013. neutral: []
  1014. }
  1015. # Analyze logos and visual assets for color extraction
  1016. @visual_assets.where(asset_type: ['logo', 'image']).each do |asset|
  1017. if asset.metadata['dominant_colors'].present?
  1018. colors[:primary].concat(asset.metadata['dominant_colors'].first(2))
  1019. colors[:secondary].concat(asset.metadata['dominant_colors'][2..4] || [])
  1020. end
  1021. end
  1022. # Process and deduplicate colors
  1023. {
  1024. primary: cluster_similar_colors(colors[:primary]).first(3),
  1025. secondary: cluster_similar_colors(colors[:secondary]).first(4),
  1026. accent: detect_accent_colors(colors),
  1027. neutral: detect_neutral_colors(colors),
  1028. color_relationships: analyze_color_relationships(colors)
  1029. }
  1030. end
  1031. def cluster_similar_colors(colors)
  1032. # Group similar colors together
  1033. # This is a simplified version - in production, use proper color distance algorithms
  1034. colors.uniq.sort_by { |color| color.downcase }
  1035. end
  1036. def detect_accent_colors(colors)
  1037. # Detect high-saturation colors used sparingly
  1038. []
  1039. end
  1040. def detect_neutral_colors(colors)
  1041. # Detect grays, blacks, whites
  1042. ['#FFFFFF', '#F5F5F5', '#E5E5E5', '#333333', '#000000']
  1043. end
  1044. def analyze_color_relationships(colors)
  1045. {
  1046. primary_usage: "Headers, CTAs, brand elements",
  1047. secondary_usage: "Supporting elements, backgrounds",
  1048. contrast_ratios: "Ensures accessibility"
  1049. }
  1050. end
  1051. def extract_typography_from_assets
  1052. typography = {
  1053. fonts: [],
  1054. weights: [],
  1055. sizes: []
  1056. }
  1057. # Extract from metadata if available
  1058. @visual_assets.each do |asset|
  1059. if asset.metadata['fonts'].present?
  1060. typography[:fonts].concat(Array(asset.metadata['fonts']))
  1061. end
  1062. end
  1063. # Return structured typography data
  1064. {
  1065. primary_font: typography[:fonts].first || "System Default",
  1066. secondary_font: typography[:fonts].second,
  1067. heading_hierarchy: {
  1068. h1: { size: "48px", weight: "bold" },
  1069. h2: { size: "36px", weight: "semibold" },
  1070. h3: { size: "24px", weight: "semibold" },
  1071. h4: { size: "20px", weight: "medium" }
  1072. },
  1073. body_text: {
  1074. size: "16px",
  1075. line_height: "1.5",
  1076. weight: "regular"
  1077. }
  1078. }
  1079. end
  1080. def analyze_imagery_style
  1081. image_assets = @visual_assets.where(asset_type: 'image')
  1082. return {} if image_assets.empty?
  1083. {
  1084. style_characteristics: determine_image_style(image_assets),
  1085. common_subjects: extract_image_subjects(image_assets),
  1086. color_treatment: analyze_image_color_treatment(image_assets),
  1087. composition_patterns: analyze_composition(image_assets)
  1088. }
  1089. end
  1090. def determine_image_style(assets)
  1091. # Analyze metadata for style patterns
  1092. styles = []
  1093. assets.each do |asset|
  1094. if asset.metadata['style'].present?
  1095. styles << asset.metadata['style']
  1096. end
  1097. end
  1098. # Return most common styles
  1099. {
  1100. primary_style: most_common(styles) || "modern",
  1101. characteristics: ["clean", "professional", "vibrant"]
  1102. }
  1103. end
  1104. def analyze_logo_usage
  1105. logo_assets = @visual_assets.where(asset_type: 'logo')
  1106. return {} unless logo_assets.any?
  1107. {
  1108. variations: logo_assets.pluck(:metadata).map { |m| m['variation'] }.compact.uniq,
  1109. clear_space: "Minimum clear space equal to 'x' height",
  1110. minimum_size: "No smaller than 24px height for digital",
  1111. backgrounds: {
  1112. preferred: "White or light backgrounds",
  1113. acceptable: "Brand colors with sufficient contrast",
  1114. prohibited: "Busy patterns or low contrast"
  1115. }
  1116. }
  1117. end
  1118. def calculate_visual_consistency
  1119. # Measure consistency across visual assets
  1120. consistency_factors = []
  1121. # Color consistency
  1122. if @visual_assets.any? { |a| a.metadata['dominant_colors'].present? }
  1123. color_variations = @visual_assets.map { |a| a.metadata['dominant_colors'] }.compact
  1124. consistency_factors << calculate_color_consistency(color_variations)
  1125. end
  1126. # Style consistency
  1127. if @visual_assets.any? { |a| a.metadata['style'].present? }
  1128. styles = @visual_assets.map { |a| a.metadata['style'] }.compact
  1129. consistency_factors << calculate_style_consistency(styles)
  1130. end
  1131. consistency_factors.empty? ? 0.7 : (consistency_factors.sum / consistency_factors.size).round(2)
  1132. end
  1133. def calculate_color_consistency(color_sets)
  1134. # Measure how consistent colors are across assets
  1135. 0.8 # Simplified - implement proper color distance calculation
  1136. end
  1137. def calculate_style_consistency(styles)
  1138. # Measure style consistency
  1139. unique_styles = styles.uniq.size
  1140. total_styles = styles.size
  1141. 1.0 - (unique_styles - 1).to_f / total_styles
  1142. end
  1143. def enhance_visual_analysis_with_guides(analysis, guides)
  1144. guides.each do |guide|
  1145. # Extract explicit rules from style guide text
  1146. if guide.extracted_text.present?
  1147. extracted_rules = extract_visual_rules_from_text(guide.extracted_text)
  1148. # Merge with analyzed data
  1149. analysis[:colors].merge!(extracted_rules[:colors]) if extracted_rules[:colors]
  1150. analysis[:typography].merge!(extracted_rules[:typography]) if extracted_rules[:typography]
  1151. analysis[:imagery].merge!(extracted_rules[:imagery]) if extracted_rules[:imagery]
  1152. end
  1153. end
  1154. analysis
  1155. end
  1156. def extract_visual_rules_from_text(text)
  1157. # Use LLM to extract specific visual rules from style guide text
  1158. prompt = build_visual_extraction_prompt(text)
  1159. response = llm_service.analyze(prompt, json_response: true)
  1160. parse_visual_rules_response(response)
  1161. end
  1162. def build_visual_extraction_prompt(text)
  1163. <<~PROMPT
  1164. Extract specific visual brand guidelines from this style guide text:
  1165. #{text[0..3000]}
  1166. Extract:
  1167. 1. Color codes (hex, RGB, CMYK)
  1168. 2. Font names and specifications
  1169. 3. Logo usage rules
  1170. 4. Image style requirements
  1171. 5. Spacing and layout rules
  1172. Return as structured JSON.
  1173. PROMPT
  1174. end
  1175. def parse_visual_rules_response(response)
  1176. # Parse LLM response for visual rules
  1177. {}
  1178. end
  1179. def default_voice_attributes
  1180. {
  1181. formality: default_dimension(:formality),
  1182. energy: default_dimension(:energy),
  1183. warmth: default_dimension(:warmth),
  1184. authority: default_dimension(:authority),
  1185. tone: default_tone,
  1186. style: default_style,
  1187. personality_traits: [],
  1188. linguistic_patterns: {},
  1189. emotional_tone: {}
  1190. }
  1191. end
  1192. def default_dimension(name)
  1193. {
  1194. level: VOICE_DIMENSIONS[name][2], # middle value
  1195. score: 0.5,
  1196. evidence: [],
  1197. consistency: 0.5
  1198. }
  1199. end
  1200. def default_tone
  1201. {
  1202. primary: 'professional',
  1203. secondary: [],
  1204. avoided: [],
  1205. consistency: 0.5
  1206. }
  1207. end
  1208. def default_style
  1209. {
  1210. writing: 'informative',
  1211. sentence_structure: 'varied',
  1212. vocabulary: 'intermediate',
  1213. paragraph_length: 'medium',
  1214. active_passive_ratio: 0.7
  1215. }
  1216. end
  1217. def calculate_dimension_consistency(dimensions)
  1218. return 1.0 if dimensions.size <= 1
  1219. # Check how consistent the level is across chunks
  1220. levels = dimensions.map { |d| d[:level] }
  1221. unique_levels = levels.uniq
  1222. # Perfect consistency = 1 unique level
  1223. # Worst consistency = all different levels
  1224. consistency = 1.0 - (unique_levels.size - 1).to_f / (VOICE_DIMENSIONS.values.first.size - 1)
  1225. consistency.round(2)
  1226. end
  1227. def calculate_voice_consistency(analyses)
  1228. # Overall consistency across all dimensions
  1229. dimension_consistencies = [:formality, :energy, :warmth, :authority].map do |dim|
  1230. analyses.first[dim][:consistency] || 0.5
  1231. end
  1232. (dimension_consistencies.sum / dimension_consistencies.size).round(2)
  1233. end
  1234. def aggregate_tone(analyses)
  1235. # Collect all tone data
  1236. all_primary = analyses.map { |a| a[:tone][:primary] }
  1237. all_secondary = analyses.flat_map { |a| a[:tone][:secondary] || [] }
  1238. all_avoided = analyses.flat_map { |a| a[:tone][:avoided] || [] }
  1239. # Count frequencies
  1240. primary_counts = all_primary.group_by(&:itself).transform_values(&:count)
  1241. secondary_counts = all_secondary.group_by(&:itself).transform_values(&:count)
  1242. {
  1243. primary: primary_counts.max_by { |_, count| count }&.first || 'professional',
  1244. secondary: secondary_counts.sort_by { |_, count| -count }
  1245. .first(3)
  1246. .map(&:first),
  1247. avoided: all_avoided.group_by(&:itself)
  1248. .select { |_, instances| instances.size > 1 }
  1249. .keys,
  1250. consistency: calculate_tone_consistency(analyses),
  1251. distribution: primary_counts
  1252. }
  1253. end
  1254. def calculate_tone_consistency(analyses)
  1255. primary_tones = analyses.map { |a| a[:tone][:primary] }
  1256. unique_primary = primary_tones.uniq
  1257. # More consistent if fewer unique primary tones
  1258. 1.0 - (unique_primary.size - 1).to_f / analyses.size
  1259. end
  1260. def aggregate_style(analyses)
  1261. styles = analyses.map { |a| a[:style] }.compact
  1262. {
  1263. writing: most_common(styles.map { |s| s[:writing] }),
  1264. sentence_structure: most_common(styles.map { |s| s[:sentence_structure] }),
  1265. vocabulary: most_common(styles.map { |s| s[:vocabulary] }),
  1266. paragraph_length: most_common(styles.map { |s| s[:paragraph_length] }),
  1267. active_passive_ratio: (styles.map { |s| s[:active_passive_ratio] }.sum / styles.size).round(2)
  1268. }
  1269. end
  1270. def aggregate_personality_traits(analyses)
  1271. all_traits = analyses.flat_map { |a| a[:personality_traits] || [] }
  1272. trait_counts = all_traits.group_by(&:downcase).transform_values(&:count)
  1273. # Sort by frequency and take top traits
  1274. trait_counts.sort_by { |_, count| -count }
  1275. .first(7)
  1276. .map { |trait, count|
  1277. {
  1278. trait: all_traits.find { |t| t.downcase == trait },
  1279. frequency: count,
  1280. strength: count.to_f / analyses.size
  1281. }
  1282. }
  1283. end
  1284. def aggregate_patterns(analyses)
  1285. patterns = {
  1286. common_phrases: [],
  1287. power_words: [],
  1288. transitions: [],
  1289. openings: [],
  1290. closings: []
  1291. }
  1292. analyses.each do |analysis|
  1293. next unless analysis[:linguistic_patterns].is_a?(Hash)
  1294. analysis[:linguistic_patterns].each do |key, values|
  1295. patterns[key.to_sym] ||= []
  1296. patterns[key.to_sym].concat(Array(values))
  1297. end
  1298. end
  1299. # Deduplicate and count frequencies
  1300. patterns.transform_values do |values|
  1301. values.group_by(&:downcase)
  1302. .select { |_, instances| instances.size > 1 }
  1303. .sort_by { |_, instances| -instances.size }
  1304. .first(10)
  1305. .map { |_, instances| instances.first }
  1306. end
  1307. end
  1308. def aggregate_emotional_tone(analyses)
  1309. emotions = analyses.map { |a| a[:emotional_tone] }.compact
  1310. return {} if emotions.empty?
  1311. {
  1312. primary_emotion: most_common(emotions.map { |e| e[:primary_emotion] }),
  1313. emotional_range: most_common(emotions.map { |e| e[:emotional_range] }),
  1314. positivity_ratio: (emotions.map { |e| e[:positivity_ratio] || 0.5 }.sum / emotions.size).round(2)
  1315. }
  1316. end
  1317. def most_common(array)
  1318. return nil if array.empty?
  1319. array.group_by(&:itself).max_by { |_, v| v.size }&.first
  1320. end
  1321. def validate_patterns(patterns_data)
  1322. return {} unless patterns_data.is_a?(Hash)
  1323. {
  1324. common_phrases: Array(patterns_data['common_phrases']).first(10),
  1325. power_words: Array(patterns_data['power_words']).first(10),
  1326. transitions: Array(patterns_data['transitions']).first(5),
  1327. openings: Array(patterns_data['openings']).first(5),
  1328. closings: Array(patterns_data['closings']).first(5)
  1329. }
  1330. end
  1331. def validate_emotional_tone(emotional_data)
  1332. return {} unless emotional_data.is_a?(Hash)
  1333. {
  1334. primary_emotion: emotional_data['primary_emotion'] || 'neutral',
  1335. emotional_range: emotional_data['emotional_range'] || 'moderate',
  1336. positivity_ratio: [emotional_data['positivity_ratio'].to_f, 1.0].min
  1337. }
  1338. end
  1339. def cross_validate_findings(voice_attrs, brand_vals, messaging_pillars, guidelines)
  1340. # Cross-reference all findings for consistency
  1341. validated = {
  1342. voice_attributes: voice_attrs,
  1343. brand_values: brand_vals,
  1344. messaging_pillars: messaging_pillars,
  1345. guidelines: guidelines
  1346. }
  1347. # Validate voice attributes against guidelines
  1348. voice_guideline_alignment = validate_voice_against_guidelines(voice_attrs, guidelines)
  1349. # Validate brand values against messaging pillars
  1350. value_pillar_alignment = validate_values_against_pillars(brand_vals, messaging_pillars)
  1351. # Validate tone consistency across all elements
  1352. tone_consistency = validate_tone_consistency(voice_attrs, guidelines, messaging_pillars)
  1353. # Add validation metadata
  1354. validated[:validation_results] = {
  1355. voice_guideline_alignment: voice_guideline_alignment,
  1356. value_pillar_alignment: value_pillar_alignment,
  1357. tone_consistency: tone_consistency,
  1358. overall_coherence: calculate_overall_coherence(voice_guideline_alignment, value_pillar_alignment, tone_consistency)
  1359. }
  1360. # Adjust findings based on validation
  1361. if validated[:validation_results][:overall_coherence] < 0.7
  1362. validated = reconcile_inconsistencies(validated)
  1363. end
  1364. validated
  1365. end
  1366. def validate_voice_against_guidelines(voice_attrs, guidelines)
  1367. alignment_score = 1.0
  1368. misalignments = []
  1369. # Check if voice formality matches guideline requirements
  1370. if guidelines[:voice_tone_rules][:must_do]
  1371. formal_guidelines = guidelines[:voice_tone_rules][:must_do].select { |rule|
  1372. rule.downcase.include?('formal') || rule.downcase.include?('professional')
  1373. }
  1374. if formal_guidelines.any? && voice_attrs[:formality][:level] == 'very_casual'
  1375. alignment_score -= 0.3
  1376. misalignments << "Voice formality conflicts with guidelines"
  1377. end
  1378. end
  1379. # Check tone alignment
  1380. prohibited_tones = guidelines[:voice_tone_rules][:must_not_do] || []
  1381. used_tones = [voice_attrs[:tone][:primary]] + (voice_attrs[:tone][:secondary] || [])
  1382. conflicts = used_tones.select { |tone|
  1383. prohibited_tones.any? { |rule| rule.downcase.include?(tone.downcase) }
  1384. }
  1385. if conflicts.any?
  1386. alignment_score -= 0.2 * conflicts.size
  1387. misalignments << "Conflicting tones: #{conflicts.join(', ')}"
  1388. end
  1389. {
  1390. score: [alignment_score, 0].max,
  1391. misalignments: misalignments,
  1392. recommendation: alignment_score < 0.7 ? "Review and reconcile voice guidelines" : "Good alignment"
  1393. }
  1394. end
  1395. def validate_values_against_pillars(brand_values, messaging_pillars)
  1396. # Check if brand values are reflected in messaging pillars
  1397. values = brand_values.map { |v| v[:name].downcase }
  1398. pillar_content = messaging_pillars[:pillars].flat_map { |p|
  1399. [p[:name], p[:description]] + p[:key_messages]
  1400. }.join(' ').downcase
  1401. reflected_values = values.select { |value|
  1402. pillar_content.include?(value) ||
  1403. pillar_content.include?(value.gsub('-', ' '))
  1404. }
  1405. alignment_score = reflected_values.size.to_f / values.size
  1406. {
  1407. score: alignment_score,
  1408. reflected: reflected_values,
  1409. missing: values - reflected_values,
  1410. recommendation: alignment_score < 0.6 ? "Strengthen value representation in messaging" : "Values well represented"
  1411. }
  1412. end
  1413. def validate_tone_consistency(voice_attrs, guidelines, messaging_pillars)
  1414. all_tones = []
  1415. # Collect tones from voice analysis
  1416. all_tones << voice_attrs[:tone][:primary]
  1417. all_tones.concat(voice_attrs[:tone][:secondary] || [])
  1418. # Collect implied tones from guidelines
  1419. guideline_text = guidelines.values.flatten.join(' ').downcase
  1420. TONE_ATTRIBUTES.each do |tone|
  1421. all_tones << tone if guideline_text.include?(tone.downcase)
  1422. end
  1423. # Collect tones from messaging pillars
  1424. pillars_text = messaging_pillars[:pillars].map { |p| p[:target_emotion] }.compact
  1425. all_tones.concat(pillars_text)
  1426. # Calculate consistency
  1427. tone_groups = all_tones.group_by(&:downcase)
  1428. consistency_score = tone_groups.values.map(&:size).max.to_f / all_tones.size
  1429. {
  1430. score: consistency_score,
  1431. dominant_tones: tone_groups.sort_by { |_, v| -v.size }.first(3).map(&:first),
  1432. variation: 1.0 - consistency_score,
  1433. recommendation: consistency_score < 0.5 ? "Establish clearer tone direction" : "Consistent tone usage"
  1434. }
  1435. end
  1436. def calculate_overall_coherence(voice_alignment, value_alignment, tone_consistency)
  1437. weights = {
  1438. voice: 0.35,
  1439. values: 0.35,
  1440. tone: 0.30
  1441. }
  1442. (
  1443. voice_alignment[:score] * weights[:voice] +
  1444. value_alignment[:score] * weights[:values] +
  1445. tone_consistency[:score] * weights[:tone]
  1446. ).round(2)
  1447. end
  1448. def reconcile_inconsistencies(validated)
  1449. # Adjust findings to resolve major inconsistencies
  1450. coherence = validated[:validation_results][:overall_coherence]
  1451. if coherence < 0.5
  1452. # Major inconsistencies - flag for manual review
  1453. validated[:requires_manual_review] = true
  1454. validated[:inconsistency_notes] = generate_inconsistency_report(validated[:validation_results])
  1455. elsif coherence < 0.7
  1456. # Minor inconsistencies - attempt automatic reconciliation
  1457. # Adjust secondary tones that conflict
  1458. if validated[:validation_results][:voice_guideline_alignment][:misalignments].any?
  1459. conflicting_tones = validated[:voice_attributes][:tone][:secondary].select { |tone|
  1460. validated[:guidelines][:voice_tone_rules][:must_not_do]&.any? { |rule|
  1461. rule.downcase.include?(tone.downcase)
  1462. }
  1463. }
  1464. validated[:voice_attributes][:tone][:secondary] -= conflicting_tones
  1465. validated[:voice_attributes][:tone][:avoided] = conflicting_tones
  1466. end
  1467. end
  1468. validated
  1469. end
  1470. def generate_inconsistency_report(validation_results)
  1471. report = []
  1472. if validation_results[:voice_guideline_alignment][:score] < 0.7
  1473. report << "Voice attributes conflict with stated guidelines: #{validation_results[:voice_guideline_alignment][:misalignments].join('; ')}"
  1474. end
  1475. if validation_results[:value_pillar_alignment][:score] < 0.6
  1476. report << "Brand values not well reflected in messaging: Missing #{validation_results[:value_pillar_alignment][:missing].join(', ')}"
  1477. end
  1478. if validation_results[:tone_consistency][:score] < 0.5
  1479. report << "Inconsistent tone usage across brand materials"
  1480. end
  1481. report
  1482. end
  1483. def extract_image_subjects(assets)
  1484. subjects = []
  1485. assets.each do |asset|
  1486. if asset.metadata['subjects'].present?
  1487. subjects.concat(Array(asset.metadata['subjects']))
  1488. end
  1489. end
  1490. subjects.group_by(&:itself)
  1491. .sort_by { |_, instances| -instances.size }
  1492. .first(10)
  1493. .map { |subject, _| subject }
  1494. end
  1495. def analyze_image_color_treatment(assets)
  1496. treatments = []
  1497. assets.each do |asset|
  1498. if asset.metadata['color_treatment'].present?
  1499. treatments << asset.metadata['color_treatment']
  1500. end
  1501. end
  1502. {
  1503. dominant_treatment: most_common(treatments) || "natural",
  1504. variations: treatments.uniq
  1505. }
  1506. end
  1507. def analyze_composition(assets)
  1508. compositions = []
  1509. assets.each do |asset|
  1510. if asset.metadata['composition'].present?
  1511. compositions << asset.metadata['composition']
  1512. end
  1513. end
  1514. {
  1515. common_patterns: compositions.group_by(&:itself)
  1516. .sort_by { |_, v| -v.size }
  1517. .first(5)
  1518. .map(&:first),
  1519. guidelines: "Follow rule of thirds, maintain visual hierarchy"
  1520. }
  1521. end
  1522. def calculate_comprehensive_confidence_score(validated_data)
  1523. scores = {}
  1524. # Content volume score
  1525. content_score = calculate_content_volume_score
  1526. scores[:content_volume] = content_score
  1527. # Voice consistency score
  1528. voice_consistency = validated_data[:voice_attributes][:consistency_score] || 0.5
  1529. scores[:voice_consistency] = voice_consistency
  1530. # Value extraction confidence
  1531. value_confidence = calculate_value_extraction_confidence(validated_data[:brand_values])
  1532. scores[:value_confidence] = value_confidence
  1533. # Messaging clarity score
  1534. messaging_clarity = calculate_messaging_clarity(validated_data[:messaging_pillars])
  1535. scores[:messaging_clarity] = messaging_clarity
  1536. # Guidelines completeness
  1537. guidelines_completeness = calculate_guidelines_completeness(validated_data[:guidelines])
  1538. scores[:guidelines_completeness] = guidelines_completeness
  1539. # Visual analysis confidence (if applicable)
  1540. if validated_data[:visual_guidelines].present? && validated_data[:visual_guidelines].any?
  1541. visual_confidence = validated_data[:visual_guidelines][:visual_consistency] || 0.5
  1542. scores[:visual_confidence] = visual_confidence
  1543. end
  1544. # Cross-validation score
  1545. validation_score = validated_data[:validation_results][:overall_coherence] || 0.7
  1546. scores[:cross_validation] = validation_score
  1547. # Calculate weighted overall score
  1548. weights = {
  1549. content_volume: 0.15,
  1550. voice_consistency: 0.20,
  1551. value_confidence: 0.15,
  1552. messaging_clarity: 0.15,
  1553. guidelines_completeness: 0.15,
  1554. visual_confidence: 0.10,
  1555. cross_validation: 0.20
  1556. }
  1557. overall_score = scores.sum { |key, score|
  1558. weight = weights[key] || 0
  1559. score * weight
  1560. }
  1561. {
  1562. overall: overall_score.round(2),
  1563. breakdown: scores,
  1564. confidence_level: determine_confidence_level(overall_score),
  1565. recommendations: generate_confidence_recommendations(scores)
  1566. }
  1567. end
  1568. def calculate_content_volume_score
  1569. word_count = @content.split.size
  1570. source_count = @content_sources&.size || 1
  1571. # Score based on word count
  1572. volume_score = case word_count
  1573. when 0..500 then 0.2
  1574. when 501..1000 then 0.4
  1575. when 1001..3000 then 0.6
  1576. when 3001..7000 then 0.8
  1577. when 7001..15000 then 0.9
  1578. else 1.0
  1579. end
  1580. # Bonus for multiple sources
  1581. source_bonus = [source_count * 0.05, 0.2].min
  1582. [volume_score + source_bonus, 1.0].min
  1583. end
  1584. def calculate_value_extraction_confidence(brand_values)
  1585. return 0.3 if brand_values.empty?
  1586. # Average confidence of top values
  1587. top_values = brand_values.first(5)
  1588. avg_score = top_values.map { |v| v[:score] }.sum / top_values.size
  1589. # Bonus for explicit values
  1590. explicit_count = brand_values.count { |v| v[:type] == :explicit }
  1591. explicit_bonus = [explicit_count * 0.1, 0.3].min
  1592. [avg_score + explicit_bonus, 1.0].min
  1593. end
  1594. def calculate_messaging_clarity(messaging_data)
  1595. return 0.3 unless messaging_data[:pillars].any?
  1596. pillars = messaging_data[:pillars]
  1597. # Score based on pillar strength and consistency
  1598. avg_strength = pillars.map { |p| p[:strength_score] }.sum / pillars.size
  1599. avg_consistency = pillars.map { |p| p[:consistency_score] }.sum / pillars.size
  1600. (avg_strength * 0.6 + avg_consistency * 0.4).round(2)
  1601. end
  1602. def calculate_guidelines_completeness(guidelines)
  1603. total_categories = 5 # voice, messaging, visual, grammar, behavioral
  1604. populated_categories = 0
  1605. total_rules = 0
  1606. [:voice_tone_rules, :messaging_rules, :visual_rules, :grammar_style_rules, :behavioral_rules].each do |category|
  1607. if guidelines[category].present? && guidelines[category].any? { |_, v| v.present? && v.any? }
  1608. populated_categories += 1
  1609. total_rules += guidelines[category].values.flatten.size
  1610. end
  1611. end
  1612. category_score = populated_categories.to_f / total_categories
  1613. # Bonus for having many specific rules
  1614. rule_bonus = case total_rules
  1615. when 0..5 then 0
  1616. when 6..15 then 0.1
  1617. when 16..30 then 0.2
  1618. else 0.3
  1619. end
  1620. [category_score + rule_bonus, 1.0].min
  1621. end
  1622. def determine_confidence_level(score)
  1623. case score
  1624. when 0.9..1.0 then "Very High"
  1625. when 0.75..0.89 then "High"
  1626. when 0.6..0.74 then "Moderate"
  1627. when 0.4..0.59 then "Low"
  1628. else "Very Low"
  1629. end
  1630. end
  1631. def generate_confidence_recommendations(scores)
  1632. recommendations = []
  1633. scores.each do |aspect, score|
  1634. if score < 0.6
  1635. case aspect
  1636. when :content_volume
  1637. recommendations << "Upload more brand materials for comprehensive analysis"
  1638. when :voice_consistency
  1639. recommendations << "Review brand voice for consistency across materials"
  1640. when :value_confidence
  1641. recommendations << "Clarify and explicitly state core brand values"
  1642. when :messaging_clarity
  1643. recommendations << "Develop clearer messaging pillars and key messages"
  1644. when :guidelines_completeness
  1645. recommendations << "Create more comprehensive brand guidelines"
  1646. when :visual_confidence
  1647. recommendations << "Ensure visual assets follow consistent style"
  1648. when :cross_validation
  1649. recommendations << "Align voice, values, and messaging for coherence"
  1650. end
  1651. end
  1652. end
  1653. recommendations
  1654. end
  1655. def create_comprehensive_guidelines(analysis)
  1656. guidelines = []
  1657. # Process each category of rules
  1658. process_voice_tone_guidelines(analysis, guidelines)
  1659. process_messaging_guidelines(analysis, guidelines)
  1660. process_visual_guidelines(analysis, guidelines)
  1661. process_grammar_style_guidelines(analysis, guidelines)
  1662. process_behavioral_guidelines(analysis, guidelines)
  1663. # Create high-priority rules from rule_priorities
  1664. if analysis.extracted_rules[:rule_priorities]
  1665. create_priority_guidelines(analysis.extracted_rules[:rule_priorities], guidelines)
  1666. end
  1667. guidelines
  1668. end
  1669. def process_voice_tone_guidelines(analysis, guidelines)
  1670. rules = analysis.extracted_rules[:voice_tone_rules] || {}
  1671. # Must do rules
  1672. rules[:must_do]&.each_with_index do |rule, index|
  1673. guidelines << brand.brand_guidelines.create!(
  1674. rule_type: "must",
  1675. rule_content: rule,
  1676. category: "voice",
  1677. priority: 9 - (index * 0.1),
  1678. metadata: { source: "analysis", confidence: analysis.confidence_score }
  1679. )
  1680. end
  1681. # Should do rules
  1682. rules[:should_do]&.each_with_index do |rule, index|
  1683. guidelines << brand.brand_guidelines.create!(
  1684. rule_type: "should",
  1685. rule_content: rule,
  1686. category: "voice",
  1687. priority: 7 - (index * 0.1),
  1688. metadata: { source: "analysis" }
  1689. )
  1690. end
  1691. # Must not do rules
  1692. rules[:must_not_do]&.each_with_index do |rule, index|
  1693. guidelines << brand.brand_guidelines.create!(
  1694. rule_type: "must_not",
  1695. rule_content: rule,
  1696. category: "voice",
  1697. priority: 8 - (index * 0.1),
  1698. metadata: { source: "analysis" }
  1699. )
  1700. end
  1701. end
  1702. def process_messaging_guidelines(analysis, guidelines)
  1703. rules = analysis.extracted_rules[:messaging_rules] || {}
  1704. # Required elements
  1705. rules[:required_elements]&.each do |element|
  1706. guidelines << brand.brand_guidelines.create!(
  1707. rule_type: "must",
  1708. rule_content: "Include: #{element}",
  1709. category: "messaging",
  1710. priority: 8.5,
  1711. metadata: { element_type: "required" }
  1712. )
  1713. end
  1714. # Key phrases
  1715. if rules[:key_phrases]&.any?
  1716. guidelines << brand.brand_guidelines.create!(
  1717. rule_type: "should",
  1718. rule_content: "Use key phrases: #{rules[:key_phrases].join(', ')}",
  1719. category: "messaging",
  1720. priority: 7,
  1721. metadata: { phrases: rules[:key_phrases] }
  1722. )
  1723. end
  1724. # Prohibited topics
  1725. rules[:prohibited_topics]&.each do |topic|
  1726. guidelines << brand.brand_guidelines.create!(
  1727. rule_type: "must_not",
  1728. rule_content: "Avoid discussing: #{topic}",
  1729. category: "messaging",
  1730. priority: 8,
  1731. metadata: { topic_type: "prohibited" }
  1732. )
  1733. end
  1734. end
  1735. def process_visual_guidelines(analysis, guidelines)
  1736. visual = analysis.extracted_rules[:visual_rules] || {}
  1737. # Color rules
  1738. if visual[:colors]&.any? { |_, v| v.present? && v.any? }
  1739. color_rule = build_color_rule(visual[:colors])
  1740. guidelines << brand.brand_guidelines.create!(
  1741. rule_type: "must",
  1742. rule_content: color_rule,
  1743. category: "visual",
  1744. priority: 9,
  1745. metadata: { colors: visual[:colors] }
  1746. )
  1747. end
  1748. # Typography rules
  1749. if visual[:typography][:fonts]&.any?
  1750. guidelines << brand.brand_guidelines.create!(
  1751. rule_type: "must",
  1752. rule_content: "Use fonts: #{visual[:typography][:fonts].join(', ')}",
  1753. category: "visual",
  1754. priority: 8.5,
  1755. metadata: { typography: visual[:typography] }
  1756. )
  1757. end
  1758. # Imagery rules
  1759. if visual[:imagery][:do]&.any?
  1760. guidelines << brand.brand_guidelines.create!(
  1761. rule_type: "should",
  1762. rule_content: "Image style: #{visual[:imagery][:style]}. #{visual[:imagery][:do].first(3).join('; ')}",
  1763. category: "visual",
  1764. priority: 7
  1765. )
  1766. end
  1767. if visual[:imagery][:dont]&.any?
  1768. guidelines << brand.brand_guidelines.create!(
  1769. rule_type: "must_not",
  1770. rule_content: "Avoid: #{visual[:imagery][:dont].first(3).join('; ')}",
  1771. category: "visual",
  1772. priority: 7.5
  1773. )
  1774. end
  1775. end
  1776. def build_color_rule(colors)
  1777. parts = []
  1778. parts << "Primary colors: #{colors[:primary].join(', ')}" if colors[:primary]&.any?
  1779. parts << "Secondary colors: #{colors[:secondary].join(', ')}" if colors[:secondary]&.any?
  1780. parts.join('. ')
  1781. end
  1782. def process_grammar_style_guidelines(analysis, guidelines)
  1783. rules = analysis.extracted_rules[:grammar_style_rules] || {}
  1784. # Combine all grammar rules into comprehensive guidelines
  1785. if rules.any? { |_, v| v.present? && v.any? }
  1786. style_rules = []
  1787. style_rules.concat(rules[:punctuation] || [])
  1788. style_rules.concat(rules[:capitalization] || [])
  1789. style_rules.concat(rules[:formatting] || [])
  1790. if style_rules.any?
  1791. guidelines << brand.brand_guidelines.create!(
  1792. rule_type: "must",
  1793. rule_content: "Follow style rules: #{style_rules.first(5).join('; ')}",
  1794. category: "grammar",
  1795. priority: 7,
  1796. metadata: { style_rules: rules }
  1797. )
  1798. end
  1799. end
  1800. # Preferred terms
  1801. if rules[:preferred_terms]&.any?
  1802. term_guidelines = rules[:preferred_terms].map { |preferred, avoid|
  1803. "Use '#{preferred}' instead of '#{avoid}'"
  1804. }
  1805. guidelines << brand.brand_guidelines.create!(
  1806. rule_type: "should",
  1807. rule_content: term_guidelines.join('; '),
  1808. category: "grammar",
  1809. priority: 6.5,
  1810. metadata: { terms: rules[:preferred_terms] }
  1811. )
  1812. end
  1813. end
  1814. def process_behavioral_guidelines(analysis, guidelines)
  1815. rules = analysis.extracted_rules[:behavioral_rules] || {}
  1816. # Customer interaction rules
  1817. rules[:customer_interaction]&.each do |rule|
  1818. guidelines << brand.brand_guidelines.create!(
  1819. rule_type: "must",
  1820. rule_content: rule,
  1821. category: "behavior",
  1822. priority: 8,
  1823. metadata: { interaction_type: "customer" }
  1824. )
  1825. end
  1826. # Response patterns
  1827. if rules[:response_patterns]&.any?
  1828. guidelines << brand.brand_guidelines.create!(
  1829. rule_type: "should",
  1830. rule_content: "Response approach: #{rules[:response_patterns].join('; ')}",
  1831. category: "behavior",
  1832. priority: 7
  1833. )
  1834. end
  1835. # Ethical guidelines
  1836. rules[:ethical_guidelines]&.each do |guideline|
  1837. guidelines << brand.brand_guidelines.create!(
  1838. rule_type: "must",
  1839. rule_content: guideline,
  1840. category: "behavior",
  1841. priority: 9,
  1842. metadata: { guideline_type: "ethical" }
  1843. )
  1844. end
  1845. end
  1846. def create_priority_guidelines(priorities, guidelines)
  1847. # Create guidelines for the highest priority rules
  1848. priorities.select { |p| p[:importance] >= 8 }.each do |priority_rule|
  1849. existing = guidelines.find { |g|
  1850. g.rule_content.downcase.include?(priority_rule[:rule].downcase)
  1851. }
  1852. unless existing
  1853. guidelines << brand.brand_guidelines.create!(
  1854. rule_type: "must",
  1855. rule_content: priority_rule[:rule],
  1856. category: priority_rule[:category] || "general",
  1857. priority: priority_rule[:importance],
  1858. metadata: {
  1859. consequences: priority_rule[:consequences],
  1860. source: "high_priority_analysis"
  1861. }
  1862. )
  1863. end
  1864. end
  1865. end
  1866. def update_messaging_framework_detailed(analysis)
  1867. framework = brand.messaging_framework || brand.build_messaging_framework
  1868. # Extract comprehensive tone data
  1869. tone_data = {
  1870. primary: analysis.voice_attributes[:tone][:primary],
  1871. secondary: analysis.voice_attributes[:tone][:secondary],
  1872. avoided: analysis.voice_attributes[:tone][:avoided],
  1873. emotional_tone: analysis.voice_attributes[:emotional_tone],
  1874. consistency: analysis.voice_attributes[:tone][:consistency]
  1875. }
  1876. # Build structured key messages from pillars
  1877. key_messages = build_structured_key_messages(analysis.messaging_pillars)
  1878. # Create value propositions with evidence
  1879. value_props = build_evidence_based_value_propositions(analysis)
  1880. # Update framework with comprehensive data
  1881. framework.update!(
  1882. tone_attributes: tone_data,
  1883. key_messages: key_messages,
  1884. value_propositions: value_props,
  1885. audience_personas: extract_audience_insights(analysis),
  1886. differentiation_points: extract_differentiators(analysis),
  1887. brand_promise: generate_brand_promise(analysis),
  1888. elevator_pitch: generate_elevator_pitch(analysis)
  1889. )
  1890. framework
  1891. end
  1892. def build_structured_key_messages(messaging_pillars)
  1893. return {} unless messaging_pillars[:pillars].present?
  1894. messages = {}
  1895. messaging_pillars[:pillars].each do |pillar|
  1896. messages[pillar[:name]] = {
  1897. core_message: pillar[:description],
  1898. supporting_points: pillar[:key_messages] || [],
  1899. proof_points: pillar[:supporting_points] || [],
  1900. emotional_goal: pillar[:target_emotion],
  1901. usage_contexts: determine_usage_contexts(pillar)
  1902. }
  1903. end
  1904. # Add hierarchy information
  1905. messages[:hierarchy] = messaging_pillars[:pillar_hierarchy]
  1906. messages
  1907. end
  1908. def build_evidence_based_value_propositions(analysis)
  1909. primary_values = analysis.brand_values.first(3)
  1910. {
  1911. core_value_prop: generate_core_value_proposition(primary_values, analysis.messaging_pillars),
  1912. supporting_props: primary_values.map { |value|
  1913. {
  1914. value: value[:name],
  1915. proposition: "We deliver #{value[:name].downcase} through #{value[:contexts].first}",
  1916. evidence: value[:evidence],
  1917. strength: value[:score]
  1918. }
  1919. },
  1920. proof_points: extract_proof_points(analysis),
  1921. competitive_advantages: identify_competitive_advantages(analysis)
  1922. }
  1923. end
  1924. def generate_core_value_proposition(values, pillars)
  1925. # Generate a cohesive value proposition from top values and pillars
  1926. value_names = values.map { |v| v[:name] }.join(', ')
  1927. primary_pillar = pillars[:pillars].first
  1928. "We deliver #{value_names} by #{primary_pillar[:description].downcase}, "\
  1929. "enabling #{primary_pillar[:target_emotion] || 'success'} for our customers."
  1930. end
  1931. def extract_audience_insights(analysis)
  1932. # Extract implied audience characteristics from voice and messaging
  1933. {
  1934. communication_preferences: determine_audience_preferences(analysis.voice_attributes),
  1935. value_alignment: analysis.brand_values.map { |v| v[:name] },
  1936. emotional_drivers: extract_emotional_drivers(analysis.messaging_pillars),
  1937. sophistication_level: determine_audience_sophistication(analysis.voice_attributes)
  1938. }
  1939. end
  1940. def determine_audience_preferences(voice_attrs)
  1941. preferences = []
  1942. case voice_attrs[:formality][:level]
  1943. when 'very_formal', 'formal'
  1944. preferences << "Professional communication"
  1945. preferences << "Detailed information"
  1946. when 'casual', 'very_casual'
  1947. preferences << "Conversational tone"
  1948. preferences << "Quick, digestible content"
  1949. else
  1950. preferences << "Balanced communication style"
  1951. end
  1952. case voice_attrs[:style][:writing]
  1953. when 'technical'
  1954. preferences << "Data-driven insights"
  1955. preferences << "Specific details"
  1956. when 'storytelling'
  1957. preferences << "Narrative examples"
  1958. preferences << "Relatable scenarios"
  1959. end
  1960. preferences
  1961. end
  1962. def extract_emotional_drivers(messaging_pillars)
  1963. pillars = messaging_pillars[:pillars] || []
  1964. drivers = pillars.map { |p| p[:target_emotion] }.compact.uniq
  1965. drivers.presence || ['trust', 'confidence', 'success']
  1966. end
  1967. def determine_audience_sophistication(voice_attrs)
  1968. case voice_attrs[:style][:vocabulary]
  1969. when 'advanced', 'technical'
  1970. 'High - Expert level'
  1971. when 'intermediate'
  1972. 'Medium - Professional level'
  1973. else
  1974. 'Accessible - General audience'
  1975. end
  1976. end
  1977. def extract_differentiators(analysis)
  1978. differentiators = []
  1979. # Extract from messaging pillars
  1980. analysis.messaging_pillars[:pillars].each do |pillar|
  1981. if pillar[:name].downcase.include?('unique') ||
  1982. pillar[:name].downcase.include?('different') ||
  1983. pillar[:description].downcase.include?('only')
  1984. differentiators << {
  1985. point: pillar[:name],
  1986. description: pillar[:description],
  1987. evidence: pillar[:supporting_points]
  1988. }
  1989. end
  1990. end
  1991. # Extract from brand values that suggest differentiation
  1992. unique_values = analysis.brand_values.select { |v|
  1993. v[:score] > 0.8 && v[:type] == :explicit
  1994. }
  1995. unique_values.each do |value|
  1996. differentiators << {
  1997. point: "#{value[:name]} Leadership",
  1998. description: "Demonstrated commitment to #{value[:name].downcase}",
  1999. evidence: value[:evidence]
  2000. }
  2001. end
  2002. differentiators.first(5)
  2003. end
  2004. def generate_brand_promise(analysis)
  2005. # Create a concise brand promise from values and pillars
  2006. top_value = analysis.brand_values.first[:name]
  2007. primary_pillar = analysis.messaging_pillars[:pillars].first
  2008. "We promise to deliver #{top_value.downcase} through #{primary_pillar[:description].downcase}, "\
  2009. "ensuring #{primary_pillar[:target_emotion] || 'exceptional outcomes'} in every interaction."
  2010. end
  2011. def generate_elevator_pitch(analysis)
  2012. # Create a 30-second elevator pitch
  2013. values = analysis.brand_values.first(2).map { |v| v[:name] }.join(' and ')
  2014. pillars = analysis.messaging_pillars[:pillars].first(2)
  2015. "We are committed to #{values.downcase}, #{pillars.first[:description].downcase}. "\
  2016. "#{pillars.second ? "We also #{pillars.second[:description].downcase}, " : ''}"\
  2017. "delivering #{analysis.voice_attributes[:emotional_tone][:primary_emotion] || 'positive'} "\
  2018. "experiences that #{pillars.first[:key_messages].first&.downcase || 'drive results'}."
  2019. end
  2020. def determine_usage_contexts(pillar)
  2021. contexts = []
  2022. # Determine contexts based on pillar content
  2023. keywords = (pillar[:name] + ' ' + pillar[:description]).downcase
  2024. contexts << "Sales conversations" if keywords.include?('value') || keywords.include?('benefit')
  2025. contexts << "Marketing materials" if keywords.include?('brand') || keywords.include?('story')
  2026. contexts << "Customer support" if keywords.include?('help') || keywords.include?('support')
  2027. contexts << "Product descriptions" if keywords.include?('feature') || keywords.include?('capability')
  2028. contexts << "Executive communications" if keywords.include?('vision') || keywords.include?('leadership')
  2029. contexts.presence || ["General communications"]
  2030. end
  2031. def extract_proof_points(analysis)
  2032. proof_points = []
  2033. # Extract from pillar supporting points
  2034. analysis.messaging_pillars[:pillars].each do |pillar|
  2035. pillar[:supporting_points]&.each do |point|
  2036. proof_points << {
  2037. claim: pillar[:name],
  2038. proof: point,
  2039. strength: pillar[:strength_score]
  2040. }
  2041. end
  2042. end
  2043. # Extract from value evidence
  2044. analysis.brand_values.each do |value|
  2045. value[:evidence]&.each do |evidence|
  2046. proof_points << {
  2047. claim: value[:name],
  2048. proof: evidence,
  2049. strength: value[:score]
  2050. }
  2051. end
  2052. end
  2053. # Sort by strength and take top proof points
  2054. proof_points.sort_by { |p| -p[:strength] }.first(10)
  2055. end
  2056. def identify_competitive_advantages(analysis)
  2057. advantages = []
  2058. # Look for superlatives and unique claims in pillars
  2059. analysis.messaging_pillars[:pillars].each do |pillar|
  2060. pillar[:key_messages]&.each do |message|
  2061. if message =~ /best|first|only|unique|leading|superior/i
  2062. advantages << message
  2063. end
  2064. end
  2065. end
  2066. # Look for high-scoring explicit values
  2067. top_values = analysis.brand_values.select { |v| v[:score] > 0.85 && v[:type] == :explicit }
  2068. top_values.each do |value|
  2069. advantages << "Industry-leading commitment to #{value[:name].downcase}"
  2070. end
  2071. advantages.uniq.first(5)
  2072. end
  2073. def generate_brand_consistency_report(analysis)
  2074. # This could be expanded to create a detailed consistency report
  2075. # For now, we'll add it to the analysis notes
  2076. consistency_data = {
  2077. voice_consistency: analysis.voice_attributes[:consistency_score],
  2078. value_alignment: analysis.analysis_data.dig('validation_results', 'value_pillar_alignment', 'score'),
  2079. tone_consistency: analysis.analysis_data.dig('validation_results', 'tone_consistency', 'score'),
  2080. rule_consistency: analysis.extracted_rules[:rule_consistency],
  2081. visual_consistency: analysis.visual_guidelines[:visual_consistency],
  2082. overall_coherence: analysis.analysis_data.dig('validation_results', 'overall_coherence')
  2083. }
  2084. report_summary = consistency_data.map { |aspect, score|
  2085. "#{aspect.to_s.humanize}: #{(score * 100).round}%" if score
  2086. }.compact.join(', ')
  2087. analysis.update!(
  2088. analysis_notes: (analysis.analysis_notes || '') + "\n\nConsistency Report: #{report_summary}"
  2089. )
  2090. end
  2091. def llm_service
  2092. @llm_service ||= LlmService.new(
  2093. model: @llm_provider,
  2094. temperature: @options[:temperature] || 0.7
  2095. )
  2096. end
  2097. end
  2098. end

app/services/branding/asset_processor.rb

0.0% lines covered

172 relevant lines. 0 lines covered and 172 lines missed.
    
  1. module Branding
  2. class AssetProcessor
  3. attr_reader :brand_asset, :errors
  4. def initialize(brand_asset)
  5. @brand_asset = brand_asset
  6. @errors = []
  7. end
  8. def process
  9. return false unless brand_asset.file.attached?
  10. brand_asset.mark_as_processing!
  11. begin
  12. case determine_asset_type
  13. when :pdf
  14. process_pdf
  15. when :document
  16. process_document
  17. when :image
  18. process_image
  19. when :archive
  20. process_archive
  21. else
  22. add_error("Unsupported file type: #{brand_asset.content_type}")
  23. return false
  24. end
  25. brand_asset.mark_as_completed!
  26. true
  27. rescue StandardError => e
  28. add_error("Processing failed: #{e.message}")
  29. brand_asset.mark_as_failed!(e.message)
  30. false
  31. end
  32. end
  33. private
  34. def determine_asset_type
  35. return :pdf if brand_asset.content_type == "application/pdf"
  36. return :document if brand_asset.document?
  37. return :image if brand_asset.image?
  38. return :archive if brand_asset.archive?
  39. nil
  40. end
  41. def process_pdf
  42. text = extract_pdf_text
  43. metadata = extract_pdf_metadata
  44. brand_asset.update!(
  45. extracted_text: text,
  46. extracted_data: {
  47. page_count: metadata[:page_count],
  48. title: metadata[:title],
  49. author: metadata[:author],
  50. creation_date: metadata[:creation_date]
  51. }
  52. )
  53. analyze_brand_content(text)
  54. end
  55. def extract_pdf_text
  56. text = ""
  57. brand_asset.file.blob.open do |file|
  58. reader = PDF::Reader.new(file)
  59. reader.pages.each do |page|
  60. text += page.text + "\n"
  61. end
  62. end
  63. text.strip
  64. end
  65. def extract_pdf_metadata
  66. metadata = {}
  67. brand_asset.file.blob.open do |file|
  68. reader = PDF::Reader.new(file)
  69. metadata[:page_count] = reader.page_count
  70. metadata[:title] = reader.info[:Title]
  71. metadata[:author] = reader.info[:Author]
  72. metadata[:creation_date] = reader.info[:CreationDate]
  73. end
  74. metadata
  75. end
  76. def process_document
  77. text = extract_document_text
  78. brand_asset.update!(
  79. extracted_text: text,
  80. extracted_data: {
  81. word_count: text.split.size,
  82. character_count: text.length
  83. }
  84. )
  85. analyze_brand_content(text)
  86. end
  87. def extract_document_text
  88. case brand_asset.content_type
  89. when "text/plain"
  90. extract_plain_text
  91. when "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
  92. extract_docx_text
  93. else
  94. ""
  95. end
  96. end
  97. def extract_plain_text
  98. brand_asset.file.download
  99. end
  100. def extract_docx_text
  101. text = ""
  102. brand_asset.file.blob.open do |file|
  103. doc = Docx::Document.open(file)
  104. doc.paragraphs.each do |p|
  105. text += p.to_s + "\n"
  106. end
  107. end
  108. text.strip
  109. end
  110. def process_image
  111. metadata = extract_image_metadata
  112. brand_asset.update!(
  113. extracted_data: {
  114. width: metadata[:width],
  115. height: metadata[:height],
  116. format: metadata[:format],
  117. color_profile: metadata[:color_profile],
  118. dominant_colors: extract_dominant_colors
  119. }
  120. )
  121. # For logos and visual assets, we might want to run through image recognition
  122. # or extract color palettes for brand consistency
  123. end
  124. def extract_image_metadata
  125. metadata = {}
  126. brand_asset.file.blob.analyze unless brand_asset.file.blob.analyzed?
  127. metadata[:width] = brand_asset.file.blob.metadata[:width]
  128. metadata[:height] = brand_asset.file.blob.metadata[:height]
  129. metadata[:format] = brand_asset.file.blob.content_type
  130. metadata
  131. end
  132. def extract_dominant_colors
  133. # This is a placeholder - in production, you'd use a service like
  134. # ImageMagick or a color extraction library
  135. []
  136. end
  137. def process_archive
  138. # Extract and process files within the archive
  139. extracted_files = []
  140. brand_asset.file.blob.open do |file|
  141. Zip::File.open(file) do |zip_file|
  142. zip_file.each do |entry|
  143. next if entry.directory?
  144. extracted_files << {
  145. name: entry.name,
  146. size: entry.size,
  147. type: determine_file_type(entry.name)
  148. }
  149. end
  150. end
  151. end
  152. brand_asset.update!(
  153. extracted_data: {
  154. file_count: extracted_files.size,
  155. files: extracted_files
  156. }
  157. )
  158. end
  159. def determine_file_type(filename)
  160. extension = File.extname(filename).downcase
  161. case extension
  162. when '.pdf' then 'pdf'
  163. when '.doc', '.docx' then 'document'
  164. when '.txt' then 'text'
  165. when '.jpg', '.jpeg', '.png', '.gif' then 'image'
  166. else 'other'
  167. end
  168. end
  169. def analyze_brand_content(text)
  170. return if text.blank?
  171. # Queue job for AI analysis
  172. BrandAnalysisJob.perform_later(brand_asset.brand, text)
  173. end
  174. def add_error(message)
  175. @errors << message
  176. end
  177. end
  178. end

app/services/branding/compliance/base_validator.rb

0.0% lines covered

83 relevant lines. 0 lines covered and 83 lines missed.
    
  1. module Branding
  2. module Compliance
  3. class BaseValidator
  4. attr_reader :brand, :content, :options, :violations, :suggestions
  5. def initialize(brand, content, options = {})
  6. @brand = brand
  7. @content = content
  8. @options = options
  9. @violations = []
  10. @suggestions = []
  11. end
  12. def validate
  13. raise NotImplementedError, "Subclasses must implement validate method"
  14. end
  15. protected
  16. def add_violation(type:, severity:, message:, details: {}, rule_id: nil)
  17. violation = {
  18. validator: self.class.name.demodulize.underscore,
  19. type: type,
  20. severity: severity.to_s,
  21. message: message,
  22. details: details,
  23. rule_id: rule_id,
  24. timestamp: Time.current,
  25. position: detect_position(details)
  26. }
  27. @violations << violation
  28. broadcast_violation(violation) if options[:real_time]
  29. end
  30. def add_suggestion(type:, message:, details: {}, priority: "medium", rule_id: nil)
  31. suggestion = {
  32. validator: self.class.name.demodulize.underscore,
  33. type: type,
  34. message: message,
  35. details: details,
  36. priority: priority,
  37. rule_id: rule_id,
  38. timestamp: Time.current
  39. }
  40. @suggestions << suggestion
  41. end
  42. def detect_position(details)
  43. # Attempt to find position in content for the violation
  44. if details[:text].present?
  45. index = content.index(details[:text])
  46. { start: index, end: index + details[:text].length } if index
  47. end
  48. end
  49. def broadcast_violation(violation)
  50. ActionCable.server.broadcast(
  51. "brand_compliance_#{brand.id}",
  52. {
  53. event: "violation_detected",
  54. violation: violation
  55. }
  56. )
  57. end
  58. def cache_key(suffix = nil)
  59. key_parts = [
  60. "compliance",
  61. self.class.name.underscore,
  62. brand.id,
  63. Digest::MD5.hexdigest(content.to_s)[0..10]
  64. ]
  65. key_parts << suffix if suffix
  66. key_parts.join(":")
  67. end
  68. def cached_result(key, expires_in: 5.minutes)
  69. Rails.cache.fetch(cache_key(key), expires_in: expires_in) do
  70. yield
  71. end
  72. end
  73. def severity_weight(severity)
  74. case severity.to_s
  75. when "critical" then 1.0
  76. when "high" then 0.8
  77. when "medium" then 0.5
  78. when "low" then 0.3
  79. else 0.4
  80. end
  81. end
  82. end
  83. end
  84. end

app/services/branding/compliance/cache_service.rb

0.0% lines covered

170 relevant lines. 0 lines covered and 170 lines missed.
    
  1. module Branding
  2. module Compliance
  3. class CacheService
  4. DEFAULT_EXPIRATION = 1.hour
  5. RULE_EXPIRATION = 6.hours
  6. RESULT_EXPIRATION = 30.minutes
  7. class << self
  8. def cache_store
  9. Rails.cache
  10. end
  11. # Rule caching methods
  12. def cache_rules(brand_id, rules, category = nil)
  13. key = rule_cache_key(brand_id, category)
  14. cache_store.write(key, rules, expires_in: RULE_EXPIRATION)
  15. end
  16. def get_cached_rules(brand_id, category = nil)
  17. key = rule_cache_key(brand_id, category)
  18. cache_store.read(key)
  19. end
  20. def invalidate_rules(brand_id)
  21. pattern = rule_cache_pattern(brand_id)
  22. delete_matching(pattern)
  23. end
  24. # Result caching methods
  25. def cache_validation_result(brand_id, content_hash, validator_type, result)
  26. key = result_cache_key(brand_id, content_hash, validator_type)
  27. cache_store.write(key, result, expires_in: RESULT_EXPIRATION)
  28. end
  29. def get_cached_validation_result(brand_id, content_hash, validator_type)
  30. key = result_cache_key(brand_id, content_hash, validator_type)
  31. cache_store.read(key)
  32. end
  33. # Analysis caching methods
  34. def cache_analysis(brand_id, content_hash, analysis_type, data)
  35. key = analysis_cache_key(brand_id, content_hash, analysis_type)
  36. expiration = analysis_expiration(analysis_type)
  37. cache_store.write(key, data, expires_in: expiration)
  38. end
  39. def get_cached_analysis(brand_id, content_hash, analysis_type)
  40. key = analysis_cache_key(brand_id, content_hash, analysis_type)
  41. cache_store.read(key)
  42. end
  43. # Suggestion caching methods
  44. def cache_suggestions(brand_id, violation_hash, suggestions)
  45. key = suggestion_cache_key(brand_id, violation_hash)
  46. cache_store.write(key, suggestions, expires_in: DEFAULT_EXPIRATION)
  47. end
  48. def get_cached_suggestions(brand_id, violation_hash)
  49. key = suggestion_cache_key(brand_id, violation_hash)
  50. cache_store.read(key)
  51. end
  52. # Batch operations
  53. def preload_brand_cache(brand)
  54. # Preload frequently accessed data
  55. preload_rules(brand)
  56. preload_guidelines(brand)
  57. preload_analysis_data(brand)
  58. end
  59. def clear_brand_cache(brand_id)
  60. patterns = [
  61. rule_cache_pattern(brand_id),
  62. result_cache_pattern(brand_id),
  63. analysis_cache_pattern(brand_id),
  64. suggestion_cache_pattern(brand_id)
  65. ]
  66. patterns.each { |pattern| delete_matching(pattern) }
  67. end
  68. # Statistics and monitoring
  69. def cache_statistics(brand_id)
  70. {
  71. rules_cached: count_matching(rule_cache_pattern(brand_id)),
  72. results_cached: count_matching(result_cache_pattern(brand_id)),
  73. analyses_cached: count_matching(analysis_cache_pattern(brand_id)),
  74. suggestions_cached: count_matching(suggestion_cache_pattern(brand_id)),
  75. total_size: estimate_cache_size(brand_id)
  76. }
  77. end
  78. private
  79. def rule_cache_key(brand_id, category = nil)
  80. parts = ["compliance", "rules", brand_id]
  81. parts << category if category
  82. parts.join(":")
  83. end
  84. def rule_cache_pattern(brand_id)
  85. "compliance:rules:#{brand_id}:*"
  86. end
  87. def result_cache_key(brand_id, content_hash, validator_type)
  88. ["compliance", "result", brand_id, content_hash, validator_type].join(":")
  89. end
  90. def result_cache_pattern(brand_id)
  91. "compliance:result:#{brand_id}:*"
  92. end
  93. def analysis_cache_key(brand_id, content_hash, analysis_type)
  94. ["compliance", "analysis", brand_id, content_hash, analysis_type].join(":")
  95. end
  96. def analysis_cache_pattern(brand_id)
  97. "compliance:analysis:#{brand_id}:*"
  98. end
  99. def suggestion_cache_key(brand_id, violation_hash)
  100. ["compliance", "suggestions", brand_id, violation_hash].join(":")
  101. end
  102. def suggestion_cache_pattern(brand_id)
  103. "compliance:suggestions:#{brand_id}:*"
  104. end
  105. def analysis_expiration(analysis_type)
  106. case analysis_type.to_s
  107. when "tone", "sentiment"
  108. 2.hours # These change less frequently
  109. when "readability", "keyword_density"
  110. 1.hour
  111. else
  112. DEFAULT_EXPIRATION
  113. end
  114. end
  115. def delete_matching(pattern)
  116. if cache_store.respond_to?(:delete_matched)
  117. cache_store.delete_matched(pattern)
  118. else
  119. # Fallback for cache stores that don't support pattern deletion
  120. Rails.logger.warn "Cache store doesn't support delete_matched"
  121. end
  122. end
  123. def count_matching(pattern)
  124. if cache_store.respond_to?(:keys)
  125. cache_store.keys(pattern).count
  126. else
  127. 0
  128. end
  129. end
  130. def estimate_cache_size(brand_id)
  131. # This is an estimate - actual implementation depends on cache store
  132. patterns = [
  133. rule_cache_pattern(brand_id),
  134. result_cache_pattern(brand_id),
  135. analysis_cache_pattern(brand_id),
  136. suggestion_cache_pattern(brand_id)
  137. ]
  138. total_keys = patterns.sum { |pattern| count_matching(pattern) }
  139. # Estimate 1KB average per cached item
  140. "~#{total_keys}KB"
  141. end
  142. def preload_rules(brand)
  143. # Load and cache all active rules
  144. rule_engine = RuleEngine.new(brand)
  145. categories = %w[content style visual messaging legal]
  146. categories.each do |category|
  147. rules = rule_engine.get_rules_for_category(category)
  148. cache_rules(brand.id, rules, category) if rules.any?
  149. end
  150. end
  151. def preload_guidelines(brand)
  152. # Cache frequently accessed guidelines
  153. guidelines_by_category = brand.brand_guidelines.active.group_by(&:category)
  154. guidelines_by_category.each do |category, guidelines|
  155. key = ["compliance", "guidelines", brand.id, category].join(":")
  156. cache_store.write(key, guidelines.map(&:attributes), expires_in: RULE_EXPIRATION)
  157. end
  158. end
  159. def preload_analysis_data(brand)
  160. # Cache brand analysis data
  161. if latest_analysis = brand.latest_analysis
  162. key = ["compliance", "brand_analysis", brand.id].join(":")
  163. cache_store.write(key, {
  164. voice_attributes: latest_analysis.voice_attributes,
  165. sentiment_profile: latest_analysis.sentiment_profile,
  166. keywords: latest_analysis.keywords,
  167. emotional_targets: latest_analysis.emotional_targets
  168. }, expires_in: 6.hours)
  169. end
  170. end
  171. end
  172. # Instance methods for request-scoped caching
  173. def initialize
  174. @request_cache = {}
  175. end
  176. def fetch(key, &block)
  177. @request_cache[key] ||= block.call
  178. end
  179. def clear
  180. @request_cache.clear
  181. end
  182. end
  183. end
  184. end

app/services/branding/compliance/event_broadcaster.rb

0.0% lines covered

115 relevant lines. 0 lines covered and 115 lines missed.
    
  1. module Branding
  2. module Compliance
  3. class EventBroadcaster
  4. attr_reader :brand_id, :session_id, :user_id
  5. def initialize(brand_id, session_id = nil, user_id = nil)
  6. @brand_id = brand_id
  7. @session_id = session_id
  8. @user_id = user_id
  9. end
  10. def broadcast_validation_start(content_info = {})
  11. broadcast_event("validation_started", {
  12. content_type: content_info[:type],
  13. content_length: content_info[:length],
  14. validators: content_info[:validators]
  15. })
  16. end
  17. def broadcast_validator_progress(validator_name, progress)
  18. broadcast_event("validator_progress", {
  19. validator: validator_name,
  20. progress: progress,
  21. status: progress >= 1.0 ? "completed" : "in_progress"
  22. })
  23. end
  24. def broadcast_violation_detected(violation)
  25. broadcast_event("violation_detected", {
  26. violation: sanitize_violation(violation),
  27. timestamp: Time.current
  28. })
  29. end
  30. def broadcast_suggestion_generated(suggestion)
  31. broadcast_event("suggestion_generated", {
  32. suggestion: sanitize_suggestion(suggestion),
  33. timestamp: Time.current
  34. })
  35. end
  36. def broadcast_validation_complete(results)
  37. broadcast_event("validation_complete", {
  38. compliant: results[:compliant],
  39. score: results[:score],
  40. violations_count: results[:violations]&.count || 0,
  41. suggestions_count: results[:suggestions]&.count || 0,
  42. processing_time: results[:metadata]&.dig(:processing_time),
  43. summary: results[:summary]
  44. })
  45. end
  46. def broadcast_fix_applied(fix_info)
  47. broadcast_event("fix_applied", {
  48. violation_id: fix_info[:violation_id],
  49. fix_type: fix_info[:fix_type],
  50. confidence: fix_info[:confidence],
  51. preview: truncate_content(fix_info[:preview])
  52. })
  53. end
  54. def broadcast_error(error_info)
  55. broadcast_event("validation_error", {
  56. error_type: error_info[:type],
  57. message: error_info[:message],
  58. recoverable: error_info[:recoverable]
  59. })
  60. end
  61. private
  62. def broadcast_event(event_type, data)
  63. channels = determine_channels
  64. channels.each do |channel|
  65. ActionCable.server.broadcast(channel, {
  66. event: event_type,
  67. data: data,
  68. metadata: event_metadata
  69. })
  70. end
  71. rescue StandardError => e
  72. Rails.logger.error "Failed to broadcast compliance event: #{e.message}"
  73. end
  74. def determine_channels
  75. channels = []
  76. # Brand-wide channel
  77. channels << "brand_compliance_#{brand_id}"
  78. # Session-specific channel if available
  79. channels << "compliance_session_#{session_id}" if session_id
  80. # User-specific channel if available
  81. channels << "user_compliance_#{user_id}" if user_id
  82. channels
  83. end
  84. def event_metadata
  85. {
  86. brand_id: brand_id,
  87. session_id: session_id,
  88. user_id: user_id,
  89. timestamp: Time.current.iso8601,
  90. server_time: Time.current.to_f
  91. }
  92. end
  93. def sanitize_violation(violation)
  94. {
  95. id: violation[:id],
  96. type: violation[:type],
  97. severity: violation[:severity],
  98. message: violation[:message],
  99. validator: violation[:validator_type],
  100. position: violation[:position]
  101. }
  102. end
  103. def sanitize_suggestion(suggestion)
  104. {
  105. type: suggestion[:type],
  106. priority: suggestion[:priority],
  107. title: suggestion[:title],
  108. description: truncate_content(suggestion[:description]),
  109. effort_level: suggestion[:effort_level]
  110. }
  111. end
  112. def truncate_content(content, max_length = 200)
  113. return content if content.nil? || content.length <= max_length
  114. "#{content[0...max_length]}..."
  115. end
  116. end
  117. end
  118. end

app/services/branding/compliance/nlp_analyzer.rb

0.0% lines covered

757 relevant lines. 0 lines covered and 757 lines missed.
    
  1. module Branding
  2. module Compliance
  3. class NlpAnalyzer < BaseValidator
  4. ANALYSIS_TYPES = %i[
  5. tone sentiment readability brand_alignment
  6. keyword_density emotion style coherence
  7. ].freeze
  8. def initialize(brand, content, options = {})
  9. super
  10. @llm_service = options[:llm_service] || LlmService.new
  11. @analysis_cache = {}
  12. end
  13. def validate
  14. analyze_all_aspects
  15. # Check tone compliance
  16. check_tone_compliance
  17. # Check sentiment alignment
  18. check_sentiment_alignment
  19. # Check readability standards
  20. check_readability_standards
  21. # Check brand voice alignment
  22. check_brand_voice_alignment
  23. # Check messaging consistency
  24. check_messaging_consistency
  25. # Analyze emotional resonance
  26. check_emotional_resonance
  27. # Check style consistency
  28. check_style_consistency
  29. { violations: @violations, suggestions: @suggestions, analysis: @analysis_cache }
  30. end
  31. def analyze_aspect(aspect_type)
  32. return @analysis_cache[aspect_type] if @analysis_cache[aspect_type]
  33. analysis = case aspect_type
  34. when :tone then analyze_tone
  35. when :sentiment then analyze_sentiment
  36. when :readability then analyze_readability
  37. when :brand_alignment then analyze_brand_alignment
  38. when :keyword_density then analyze_keyword_density
  39. when :emotion then analyze_emotion
  40. when :style then analyze_style
  41. when :coherence then analyze_coherence
  42. else
  43. raise ArgumentError, "Unknown analysis type: #{aspect_type}"
  44. end
  45. @analysis_cache[aspect_type] = analysis
  46. analysis
  47. end
  48. private
  49. def analyze_all_aspects
  50. ANALYSIS_TYPES.each { |type| analyze_aspect(type) }
  51. end
  52. def analyze_tone
  53. cached_result("tone_analysis") do
  54. prompt = build_tone_analysis_prompt
  55. response = @llm_service.analyze(prompt, {
  56. json_response: true,
  57. temperature: 0.3,
  58. system_message: "You are an expert content analyst specializing in tone and voice analysis."
  59. })
  60. parse_json_response(response) || default_tone_analysis
  61. end
  62. end
  63. def analyze_sentiment
  64. cached_result("sentiment_analysis") do
  65. prompt = build_sentiment_analysis_prompt
  66. response = @llm_service.analyze(prompt, {
  67. json_response: true,
  68. temperature: 0.2
  69. })
  70. parse_json_response(response) || default_sentiment_analysis
  71. end
  72. end
  73. def analyze_readability
  74. cached_result("readability_analysis") do
  75. # Calculate various readability metrics
  76. {
  77. flesch_kincaid_score: calculate_flesch_kincaid,
  78. gunning_fog_index: calculate_gunning_fog,
  79. average_sentence_length: calculate_average_sentence_length,
  80. average_word_length: calculate_average_word_length,
  81. complex_word_percentage: calculate_complex_word_percentage,
  82. readability_grade: determine_readability_grade
  83. }
  84. end
  85. end
  86. def analyze_brand_alignment
  87. cached_result("brand_alignment_analysis") do
  88. prompt = build_brand_alignment_prompt
  89. response = @llm_service.analyze(prompt, {
  90. json_response: true,
  91. temperature: 0.4,
  92. max_tokens: 1500
  93. })
  94. parse_json_response(response) || default_brand_alignment
  95. end
  96. end
  97. def analyze_keyword_density
  98. cached_result("keyword_density_analysis") do
  99. keywords = extract_brand_keywords
  100. content_words = tokenize_content
  101. density_map = {}
  102. keywords.each do |keyword|
  103. count = content_words.count { |word| word.downcase == keyword.downcase }
  104. density = (count.to_f / content_words.length * 100).round(2)
  105. density_map[keyword] = {
  106. count: count,
  107. density: density,
  108. optimal_range: determine_optimal_density(keyword)
  109. }
  110. end
  111. {
  112. keyword_densities: density_map,
  113. total_keywords: keywords.length,
  114. content_length: content_words.length
  115. }
  116. end
  117. end
  118. def analyze_emotion
  119. cached_result("emotion_analysis") do
  120. prompt = build_emotion_analysis_prompt
  121. response = @llm_service.analyze(prompt, {
  122. json_response: true,
  123. temperature: 0.5
  124. })
  125. parse_json_response(response) || default_emotion_analysis
  126. end
  127. end
  128. def analyze_style
  129. cached_result("style_analysis") do
  130. {
  131. sentence_variety: analyze_sentence_variety,
  132. paragraph_structure: analyze_paragraph_structure,
  133. transition_usage: analyze_transitions,
  134. active_passive_ratio: calculate_active_passive_ratio,
  135. formality_level: detect_formality_level
  136. }
  137. end
  138. end
  139. def analyze_coherence
  140. cached_result("coherence_analysis") do
  141. prompt = build_coherence_analysis_prompt
  142. response = @llm_service.analyze(prompt, {
  143. json_response: true,
  144. temperature: 0.3
  145. })
  146. parse_json_response(response) || default_coherence_analysis
  147. end
  148. end
  149. # Validation checks
  150. def check_tone_compliance
  151. tone_analysis = analyze_aspect(:tone)
  152. expected_tone = brand.latest_analysis&.voice_attributes&.dig("tone", "primary") || "professional"
  153. detected_tone = tone_analysis[:primary_tone]
  154. confidence = tone_analysis[:confidence]
  155. if !tone_compatible?(detected_tone, expected_tone)
  156. add_violation(
  157. type: "tone_mismatch",
  158. severity: confidence > 0.8 ? "high" : "medium",
  159. message: "Content tone '#{detected_tone}' doesn't match brand tone '#{expected_tone}'",
  160. details: {
  161. expected: expected_tone,
  162. detected: detected_tone,
  163. confidence: confidence,
  164. secondary_tones: tone_analysis[:secondary_tones]
  165. }
  166. )
  167. elsif confidence < 0.6
  168. add_suggestion(
  169. type: "tone_clarity",
  170. message: "Consider strengthening the #{expected_tone} tone",
  171. details: {
  172. current_confidence: confidence,
  173. detected_tones: tone_analysis[:all_tones]
  174. }
  175. )
  176. end
  177. end
  178. def check_sentiment_alignment
  179. sentiment = analyze_aspect(:sentiment)
  180. brand_sentiment = brand.latest_analysis&.sentiment_profile || { "positive" => 0.7 }
  181. sentiment_score = sentiment[:overall_score]
  182. expected_range = determine_expected_sentiment_range(brand_sentiment)
  183. if !sentiment_score.between?(expected_range[:min], expected_range[:max])
  184. add_violation(
  185. type: "sentiment_misalignment",
  186. severity: "medium",
  187. message: "Content sentiment (#{sentiment_score.round(2)}) outside brand range (#{expected_range[:min]}-#{expected_range[:max]})",
  188. details: {
  189. current_sentiment: sentiment_score,
  190. expected_range: expected_range,
  191. sentiment_breakdown: sentiment[:breakdown]
  192. }
  193. )
  194. end
  195. end
  196. def check_readability_standards
  197. readability = analyze_aspect(:readability)
  198. target_grade = brand.brand_guidelines.by_category("readability").first&.metadata&.dig("target_grade") || 8
  199. current_grade = readability[:readability_grade]
  200. if (current_grade - target_grade).abs > 2
  201. severity = (current_grade - target_grade).abs > 4 ? "high" : "medium"
  202. add_violation(
  203. type: "readability_mismatch",
  204. severity: severity,
  205. message: "Readability grade #{current_grade} significantly differs from target #{target_grade}",
  206. details: {
  207. current_grade: current_grade,
  208. target_grade: target_grade,
  209. metrics: readability
  210. }
  211. )
  212. elsif (current_grade - target_grade).abs > 1
  213. add_suggestion(
  214. type: "readability_adjustment",
  215. message: "Consider adjusting readability closer to grade #{target_grade}",
  216. details: {
  217. current_grade: current_grade,
  218. suggestions: suggest_readability_improvements(readability, target_grade)
  219. }
  220. )
  221. end
  222. end
  223. def check_brand_voice_alignment
  224. alignment = analyze_aspect(:brand_alignment)
  225. alignment_score = alignment[:overall_score] || 0
  226. if alignment_score < 0.5
  227. add_violation(
  228. type: "brand_voice_misalignment",
  229. severity: "high",
  230. message: "Content doesn't align well with brand voice (#{(alignment_score * 100).round}% match)",
  231. details: {
  232. alignment_score: alignment_score,
  233. missing_elements: alignment[:missing_elements],
  234. conflicting_elements: alignment[:conflicting_elements]
  235. }
  236. )
  237. elsif alignment_score < 0.7
  238. add_suggestion(
  239. type: "brand_voice_enhancement",
  240. message: "Strengthen brand voice elements",
  241. details: {
  242. current_score: alignment_score,
  243. improvement_areas: alignment[:improvement_suggestions]
  244. },
  245. priority: "high"
  246. )
  247. end
  248. end
  249. def check_messaging_consistency
  250. brand_messages = extract_brand_messages
  251. alignment = analyze_aspect(:brand_alignment)
  252. missing_messages = alignment[:missing_key_messages] || []
  253. if missing_messages.length > brand_messages.length * 0.5
  254. add_violation(
  255. type: "key_message_absence",
  256. severity: "medium",
  257. message: "Missing #{missing_messages.length} key brand messages",
  258. details: {
  259. missing_messages: missing_messages,
  260. total_expected: brand_messages.length
  261. }
  262. )
  263. elsif missing_messages.any?
  264. add_suggestion(
  265. type: "message_incorporation",
  266. message: "Consider incorporating these key messages",
  267. details: {
  268. missing_messages: missing_messages.first(3)
  269. }
  270. )
  271. end
  272. end
  273. def check_emotional_resonance
  274. emotion = analyze_aspect(:emotion)
  275. target_emotions = brand.latest_analysis&.emotional_targets || ["trust", "confidence"]
  276. detected_emotions = emotion[:primary_emotions] || []
  277. emotion_match = (detected_emotions & target_emotions).length.to_f / target_emotions.length
  278. if emotion_match < 0.3
  279. add_violation(
  280. type: "emotional_disconnect",
  281. severity: "medium",
  282. message: "Content doesn't evoke target brand emotions",
  283. details: {
  284. target_emotions: target_emotions,
  285. detected_emotions: detected_emotions,
  286. match_percentage: (emotion_match * 100).round
  287. }
  288. )
  289. elsif emotion_match < 0.6
  290. add_suggestion(
  291. type: "emotional_enhancement",
  292. message: "Strengthen emotional connection with brand values",
  293. details: {
  294. current_emotions: detected_emotions,
  295. target_emotions: target_emotions,
  296. suggestions: suggest_emotional_improvements(emotion, target_emotions)
  297. }
  298. )
  299. end
  300. end
  301. def check_style_consistency
  302. style = analyze_aspect(:style)
  303. guidelines = brand.brand_guidelines.by_category("style")
  304. # Check sentence variety
  305. if style[:sentence_variety][:score] < 0.4
  306. add_suggestion(
  307. type: "sentence_variety",
  308. message: "Vary sentence structure for better flow",
  309. details: {
  310. current_variety: style[:sentence_variety],
  311. suggestions: ["Mix short and long sentences", "Use different sentence openings"]
  312. }
  313. )
  314. end
  315. # Check formality level
  316. expected_formality = guidelines.find { |g| g.metadata&.dig("formality_level") }&.metadata&.dig("formality_level") || "moderate"
  317. if !formality_matches?(style[:formality_level], expected_formality)
  318. add_violation(
  319. type: "formality_mismatch",
  320. severity: "low",
  321. message: "Formality level '#{style[:formality_level]}' doesn't match expected '#{expected_formality}'",
  322. details: {
  323. current: style[:formality_level],
  324. expected: expected_formality
  325. }
  326. )
  327. end
  328. end
  329. # Helper methods
  330. def build_tone_analysis_prompt
  331. <<~PROMPT
  332. Analyze the tone of the following content and provide a detailed assessment.
  333. Content:
  334. #{content}
  335. Provide analysis in this JSON structure:
  336. {
  337. "primary_tone": "professional|casual|formal|friendly|authoritative|conversational|etc",
  338. "secondary_tones": ["tone1", "tone2"],
  339. "confidence": 0.0-1.0,
  340. "all_tones": {
  341. "tone_name": confidence_score
  342. },
  343. "tone_consistency": 0.0-1.0,
  344. "tone_shifts": [
  345. {
  346. "position": "paragraph/sentence reference",
  347. "from_tone": "tone1",
  348. "to_tone": "tone2"
  349. }
  350. ]
  351. }
  352. PROMPT
  353. end
  354. def build_sentiment_analysis_prompt
  355. <<~PROMPT
  356. Analyze the sentiment of the following content.
  357. Content:
  358. #{content}
  359. Provide analysis in this JSON structure:
  360. {
  361. "overall_score": -1.0 to 1.0,
  362. "breakdown": {
  363. "positive": 0.0-1.0,
  364. "negative": 0.0-1.0,
  365. "neutral": 0.0-1.0
  366. },
  367. "sentiment_flow": [
  368. {
  369. "section": "identifier",
  370. "score": -1.0 to 1.0
  371. }
  372. ],
  373. "emotional_words": {
  374. "positive": ["word1", "word2"],
  375. "negative": ["word1", "word2"]
  376. }
  377. }
  378. PROMPT
  379. end
  380. def build_brand_alignment_prompt
  381. brand_voice = brand.brand_voice_attributes
  382. key_messages = brand.messaging_framework&.key_messages || {}
  383. <<~PROMPT
  384. Analyze how well the content aligns with the brand voice and messaging.
  385. Content:
  386. #{content}
  387. Brand Voice Attributes:
  388. #{brand_voice.to_json}
  389. Key Messages:
  390. #{key_messages.to_json}
  391. Provide analysis in this JSON structure:
  392. {
  393. "overall_score": 0.0-1.0,
  394. "voice_alignment": {
  395. "matching_attributes": ["attribute1", "attribute2"],
  396. "missing_attributes": ["attribute1", "attribute2"],
  397. "conflicting_attributes": ["attribute1", "attribute2"]
  398. },
  399. "message_alignment": {
  400. "incorporated_messages": ["message1", "message2"],
  401. "missing_key_messages": ["message1", "message2"],
  402. "message_clarity": 0.0-1.0
  403. },
  404. "improvement_suggestions": [
  405. {
  406. "area": "voice|messaging|tone",
  407. "suggestion": "specific improvement",
  408. "priority": "high|medium|low"
  409. }
  410. ],
  411. "missing_elements": ["element1", "element2"],
  412. "conflicting_elements": ["element1", "element2"]
  413. }
  414. PROMPT
  415. end
  416. def build_emotion_analysis_prompt
  417. <<~PROMPT
  418. Analyze the emotional content and impact of the following text.
  419. Content:
  420. #{content}
  421. Provide analysis in this JSON structure:
  422. {
  423. "primary_emotions": ["emotion1", "emotion2", "emotion3"],
  424. "emotion_intensity": {
  425. "emotion_name": 0.0-1.0
  426. },
  427. "emotional_arc": [
  428. {
  429. "section": "beginning|middle|end",
  430. "dominant_emotion": "emotion",
  431. "intensity": 0.0-1.0
  432. }
  433. ],
  434. "emotional_triggers": [
  435. {
  436. "phrase": "triggering phrase",
  437. "emotion": "triggered emotion",
  438. "strength": 0.0-1.0
  439. }
  440. ]
  441. }
  442. PROMPT
  443. end
  444. def build_coherence_analysis_prompt
  445. <<~PROMPT
  446. Analyze the coherence and logical flow of the following content.
  447. Content:
  448. #{content}
  449. Provide analysis in this JSON structure:
  450. {
  451. "overall_coherence": 0.0-1.0,
  452. "logical_flow": 0.0-1.0,
  453. "topic_consistency": 0.0-1.0,
  454. "transition_quality": 0.0-1.0,
  455. "issues": [
  456. {
  457. "type": "logical_gap|topic_shift|unclear_transition",
  458. "location": "paragraph/sentence reference",
  459. "severity": "high|medium|low",
  460. "suggestion": "how to fix"
  461. }
  462. ],
  463. "strengths": ["strength1", "strength2"]
  464. }
  465. PROMPT
  466. end
  467. def parse_json_response(response)
  468. return nil if response.nil? || response.empty?
  469. begin
  470. if response.is_a?(String)
  471. JSON.parse(response, symbolize_names: true)
  472. else
  473. response
  474. end
  475. rescue JSON::ParserError => e
  476. Rails.logger.error "Failed to parse LLM JSON response: #{e.message}"
  477. nil
  478. end
  479. end
  480. def calculate_flesch_kincaid
  481. sentences = content.split(/[.!?]+/).reject(&:blank?)
  482. words = tokenize_content
  483. syllables = words.sum { |word| count_syllables(word) }
  484. return 0 if sentences.empty? || words.empty?
  485. score = 206.835 - 1.015 * (words.length.to_f / sentences.length) - 84.6 * (syllables.to_f / words.length)
  486. score.round(1)
  487. end
  488. def calculate_gunning_fog
  489. sentences = content.split(/[.!?]+/).reject(&:blank?)
  490. words = tokenize_content
  491. complex_words = words.count { |word| count_syllables(word) >= 3 }
  492. return 0 if sentences.empty? || words.empty?
  493. score = 0.4 * ((words.length.to_f / sentences.length) + 100 * (complex_words.to_f / words.length))
  494. score.round(1)
  495. end
  496. def calculate_average_sentence_length
  497. sentences = content.split(/[.!?]+/).reject(&:blank?)
  498. words = tokenize_content
  499. return 0 if sentences.empty?
  500. (words.length.to_f / sentences.length).round(1)
  501. end
  502. def calculate_average_word_length
  503. words = tokenize_content
  504. return 0 if words.empty?
  505. total_length = words.sum(&:length)
  506. (total_length.to_f / words.length).round(1)
  507. end
  508. def calculate_complex_word_percentage
  509. words = tokenize_content
  510. complex_words = words.count { |word| count_syllables(word) >= 3 }
  511. return 0 if words.empty?
  512. ((complex_words.to_f / words.length) * 100).round(1)
  513. end
  514. def determine_readability_grade
  515. flesch_score = calculate_flesch_kincaid
  516. case flesch_score
  517. when 90..100 then 5
  518. when 80..89 then 6
  519. when 70..79 then 7
  520. when 60..69 then 8
  521. when 50..59 then 10
  522. when 30..49 then 13
  523. when 0..29 then 16
  524. else 12
  525. end
  526. end
  527. def tokenize_content
  528. content.downcase.gsub(/[^\w\s]/, ' ').split.reject { |w| w.length < 2 }
  529. end
  530. def count_syllables(word)
  531. return 1 if word.length <= 3
  532. word = word.downcase
  533. vowels = "aeiouy"
  534. syllable_count = 0
  535. previous_was_vowel = false
  536. word.each_char do |char|
  537. is_vowel = vowels.include?(char)
  538. if is_vowel && !previous_was_vowel
  539. syllable_count += 1
  540. end
  541. previous_was_vowel = is_vowel
  542. end
  543. # Adjust for silent e
  544. syllable_count -= 1 if word.end_with?('e') && syllable_count > 1
  545. [syllable_count, 1].max
  546. end
  547. def analyze_sentence_variety
  548. sentences = content.split(/[.!?]+/).reject(&:blank?)
  549. return { score: 0, variety: "none" } if sentences.empty?
  550. lengths = sentences.map { |s| s.split.length }
  551. # Calculate standard deviation
  552. mean = lengths.sum.to_f / lengths.length
  553. variance = lengths.sum { |l| (l - mean) ** 2 } / lengths.length
  554. std_dev = Math.sqrt(variance)
  555. # Normalize to 0-1 score
  556. variety_score = [std_dev / mean, 1.0].min
  557. {
  558. score: variety_score.round(2),
  559. variety: case variety_score
  560. when 0..0.2 then "very_low"
  561. when 0.2..0.4 then "low"
  562. when 0.4..0.6 then "moderate"
  563. when 0.6..0.8 then "good"
  564. else "excellent"
  565. end,
  566. stats: {
  567. mean_length: mean.round(1),
  568. std_deviation: std_dev.round(1),
  569. min_length: lengths.min,
  570. max_length: lengths.max
  571. }
  572. }
  573. end
  574. def analyze_paragraph_structure
  575. paragraphs = content.split(/\n\n+/).reject(&:blank?)
  576. {
  577. count: paragraphs.length,
  578. average_length: paragraphs.sum { |p| p.split.length } / paragraphs.length.to_f,
  579. consistency: calculate_paragraph_consistency(paragraphs)
  580. }
  581. end
  582. def analyze_transitions
  583. transition_words = %w[
  584. however therefore furthermore moreover consequently
  585. additionally nevertheless nonetheless meanwhile
  586. alternatively subsequently thus hence accordingly
  587. ]
  588. sentences = content.split(/[.!?]+/)
  589. transitions_used = 0
  590. sentences.each do |sentence|
  591. sentence_lower = sentence.downcase
  592. transitions_used += 1 if transition_words.any? { |t| sentence_lower.include?(t) }
  593. end
  594. {
  595. count: transitions_used,
  596. percentage: (transitions_used.to_f / sentences.length * 100).round(1),
  597. quality: transitions_used > sentences.length * 0.2 ? "good" : "needs_improvement"
  598. }
  599. end
  600. def calculate_active_passive_ratio
  601. # Simplified active/passive detection
  602. passive_indicators = /\b(was|were|been|being|is|are|am)\s+\w+ed\b/
  603. sentences = content.split(/[.!?]+/)
  604. passive_count = sentences.count { |s| s.match?(passive_indicators) }
  605. active_count = sentences.length - passive_count
  606. {
  607. active: active_count,
  608. passive: passive_count,
  609. ratio: active_count.to_f / [passive_count, 1].max
  610. }
  611. end
  612. def detect_formality_level
  613. formal_indicators = %w[therefore furthermore consequently thus hence moreover]
  614. informal_indicators = %w[gonna wanna gotta kinda sorta yeah yep nope]
  615. contractions = /\b\w+'(ll|ve|re|d|s|t)\b/
  616. content_lower = content.downcase
  617. formal_score = formal_indicators.count { |word| content_lower.include?(word) }
  618. informal_score = informal_indicators.count { |word| content_lower.include?(word) }
  619. informal_score += content.scan(contractions).length
  620. if formal_score > informal_score * 2
  621. "formal"
  622. elsif informal_score > formal_score * 2
  623. "informal"
  624. elsif formal_score > informal_score
  625. "moderate_formal"
  626. elsif informal_score > formal_score
  627. "moderate_informal"
  628. else
  629. "neutral"
  630. end
  631. end
  632. def tone_compatible?(detected, expected)
  633. compatible_tones = {
  634. "professional" => ["professional", "formal", "authoritative"],
  635. "casual" => ["casual", "conversational", "friendly"],
  636. "friendly" => ["friendly", "casual", "conversational", "warm"],
  637. "formal" => ["formal", "professional", "authoritative"],
  638. "authoritative" => ["authoritative", "professional", "formal", "expert"]
  639. }
  640. expected_group = compatible_tones[expected] || [expected]
  641. expected_group.include?(detected)
  642. end
  643. def determine_expected_sentiment_range(brand_sentiment)
  644. base_positive = brand_sentiment["positive"] || 0.7
  645. {
  646. min: base_positive - 0.2,
  647. max: [base_positive + 0.2, 1.0].min
  648. }
  649. end
  650. def suggest_readability_improvements(readability, target_grade)
  651. suggestions = []
  652. current_grade = readability[:readability_grade]
  653. if current_grade > target_grade
  654. suggestions << "Simplify complex sentences"
  655. suggestions << "Use shorter words where possible"
  656. suggestions << "Break up long paragraphs"
  657. else
  658. suggestions << "Add more descriptive language"
  659. suggestions << "Use more varied vocabulary"
  660. suggestions << "Combine short, choppy sentences"
  661. end
  662. suggestions
  663. end
  664. def extract_brand_keywords
  665. keywords = []
  666. # From messaging framework
  667. if brand.messaging_framework
  668. keywords += brand.messaging_framework.key_messages.values.flatten
  669. keywords += brand.messaging_framework.value_propositions.values.flatten
  670. end
  671. # From brand analysis
  672. if brand.latest_analysis
  673. keywords += brand.latest_analysis.keywords || []
  674. end
  675. keywords.uniq.map(&:downcase)
  676. end
  677. def extract_brand_messages
  678. messages = []
  679. if brand.messaging_framework
  680. messages += brand.messaging_framework.key_messages.values.flatten
  681. messages += brand.messaging_framework.value_propositions.values.flatten
  682. end
  683. messages.uniq
  684. end
  685. def determine_optimal_density(keyword)
  686. # Primary keywords should appear more frequently
  687. if brand.messaging_framework&.key_messages&.values&.flatten&.include?(keyword)
  688. { min: 1.0, max: 3.0 }
  689. else
  690. { min: 0.5, max: 2.0 }
  691. end
  692. end
  693. def suggest_emotional_improvements(current_emotion, target_emotions)
  694. suggestions = []
  695. missing_emotions = target_emotions - current_emotion[:primary_emotions]
  696. emotion_techniques = {
  697. "trust" => "Include testimonials, credentials, or guarantees",
  698. "excitement" => "Use dynamic language and emphasize benefits",
  699. "confidence" => "Highlight expertise and success stories",
  700. "warmth" => "Use personal anecdotes and inclusive language",
  701. "innovation" => "Emphasize cutting-edge features and forward-thinking"
  702. }
  703. missing_emotions.each do |emotion|
  704. if technique = emotion_techniques[emotion]
  705. suggestions << technique
  706. end
  707. end
  708. suggestions
  709. end
  710. def formality_matches?(detected, expected)
  711. formality_groups = {
  712. "formal" => ["formal", "moderate_formal"],
  713. "informal" => ["informal", "moderate_informal"],
  714. "neutral" => ["neutral", "moderate_formal", "moderate_informal"]
  715. }
  716. expected_group = formality_groups[expected] || [expected]
  717. expected_group.include?(detected)
  718. end
  719. def calculate_paragraph_consistency(paragraphs)
  720. return 1.0 if paragraphs.length <= 1
  721. lengths = paragraphs.map { |p| p.split.length }
  722. mean = lengths.sum.to_f / lengths.length
  723. variance = lengths.sum { |l| (l - mean) ** 2 } / lengths.length
  724. # Lower variance = more consistent
  725. consistency = 1.0 - ([Math.sqrt(variance) / mean, 1.0].min)
  726. consistency.round(2)
  727. end
  728. # Default analysis results for fallback
  729. def default_tone_analysis
  730. {
  731. primary_tone: "neutral",
  732. secondary_tones: [],
  733. confidence: 0.5,
  734. all_tones: { "neutral" => 0.5 },
  735. tone_consistency: 0.5,
  736. tone_shifts: []
  737. }
  738. end
  739. def default_sentiment_analysis
  740. {
  741. overall_score: 0.0,
  742. breakdown: { positive: 0.33, negative: 0.33, neutral: 0.34 },
  743. sentiment_flow: [],
  744. emotional_words: { positive: [], negative: [] }
  745. }
  746. end
  747. def default_brand_alignment
  748. {
  749. overall_score: 0.5,
  750. voice_alignment: {
  751. matching_attributes: [],
  752. missing_attributes: [],
  753. conflicting_attributes: []
  754. },
  755. message_alignment: {
  756. incorporated_messages: [],
  757. missing_key_messages: [],
  758. message_clarity: 0.5
  759. },
  760. improvement_suggestions: [],
  761. missing_elements: [],
  762. conflicting_elements: []
  763. }
  764. end
  765. def default_emotion_analysis
  766. {
  767. primary_emotions: ["neutral"],
  768. emotion_intensity: { "neutral" => 0.5 },
  769. emotional_arc: [],
  770. emotional_triggers: []
  771. }
  772. end
  773. def default_coherence_analysis
  774. {
  775. overall_coherence: 0.5,
  776. logical_flow: 0.5,
  777. topic_consistency: 0.5,
  778. transition_quality: 0.5,
  779. issues: [],
  780. strengths: []
  781. }
  782. end
  783. end
  784. end
  785. end

app/services/branding/compliance/rule_engine.rb

0.0% lines covered

374 relevant lines. 0 lines covered and 374 lines missed.
    
  1. module Branding
  2. module Compliance
  3. class RuleEngine
  4. attr_reader :brand, :rules_cache
  5. RULE_PRIORITIES = {
  6. mandatory: 100,
  7. critical: 90,
  8. high: 70,
  9. medium: 50,
  10. low: 30,
  11. optional: 10
  12. }.freeze
  13. def initialize(brand)
  14. @brand = brand
  15. @rules_cache = {}
  16. load_rules
  17. end
  18. def evaluate(content, context = {})
  19. results = {
  20. passed: [],
  21. failed: [],
  22. warnings: [],
  23. score: 0.0
  24. }
  25. # Get applicable rules based on context
  26. applicable_rules = filter_rules_by_context(context)
  27. # Evaluate rules in priority order
  28. applicable_rules.each do |rule|
  29. result = evaluate_rule(rule, content, context)
  30. case result[:status]
  31. when :passed
  32. results[:passed] << result
  33. when :failed
  34. results[:failed] << result
  35. when :warning
  36. results[:warnings] << result
  37. end
  38. end
  39. # Calculate compliance score
  40. results[:score] = calculate_score(results, applicable_rules)
  41. results[:rule_conflicts] = detect_conflicts(results[:failed])
  42. results
  43. end
  44. def get_rules_for_category(category)
  45. @rules_cache[category] || []
  46. end
  47. def add_dynamic_rule(rule_definition)
  48. rule = build_rule(rule_definition)
  49. category = rule[:category] || "dynamic"
  50. @rules_cache[category] ||= []
  51. @rules_cache[category] << rule
  52. # Sort by priority
  53. @rules_cache[category].sort_by! { |r| -r[:priority] }
  54. end
  55. private
  56. def load_rules
  57. # Load brand-specific guidelines
  58. load_brand_guidelines
  59. # Load global compliance rules
  60. load_global_rules
  61. # Load industry-specific rules if applicable
  62. load_industry_rules if brand.industry.present?
  63. # Cache the compiled rules
  64. cache_compiled_rules
  65. end
  66. def load_brand_guidelines
  67. brand.brand_guidelines.active.each do |guideline|
  68. rule = {
  69. id: "brand_#{guideline.id}",
  70. source: "brand_guideline",
  71. category: guideline.category,
  72. type: guideline.rule_type,
  73. content: guideline.rule_content,
  74. priority: calculate_priority(guideline),
  75. mandatory: guideline.mandatory?,
  76. metadata: guideline.metadata || {},
  77. evaluator: build_evaluator(guideline)
  78. }
  79. category = guideline.category || "general"
  80. @rules_cache[category] ||= []
  81. @rules_cache[category] << rule
  82. end
  83. end
  84. def load_global_rules
  85. # Load system-wide compliance rules
  86. global_rules = [
  87. {
  88. id: "global_profanity",
  89. category: "content",
  90. type: "must_not",
  91. content: "Content must not contain profanity",
  92. priority: RULE_PRIORITIES[:critical],
  93. mandatory: true,
  94. evaluator: ->(content, _context) { !contains_profanity?(content) }
  95. },
  96. {
  97. id: "global_legal",
  98. category: "legal",
  99. type: "must",
  100. content: "Content must include required legal disclaimers",
  101. priority: RULE_PRIORITIES[:high],
  102. mandatory: true,
  103. evaluator: ->(content, context) { check_legal_requirements(content, context) }
  104. },
  105. {
  106. id: "global_accessibility",
  107. category: "accessibility",
  108. type: "should",
  109. content: "Content should follow accessibility guidelines",
  110. priority: RULE_PRIORITIES[:medium],
  111. mandatory: false,
  112. evaluator: ->(content, context) { check_accessibility(content, context) }
  113. }
  114. ]
  115. global_rules.each do |rule|
  116. category = rule[:category]
  117. @rules_cache[category] ||= []
  118. @rules_cache[category] << rule
  119. end
  120. end
  121. def load_industry_rules
  122. # Load industry-specific compliance rules
  123. industry_rules = Rails.cache.fetch("industry_rules:#{brand.industry}", expires_in: 1.day) do
  124. case brand.industry
  125. when "healthcare"
  126. load_healthcare_rules
  127. when "finance"
  128. load_finance_rules
  129. when "technology"
  130. load_technology_rules
  131. else
  132. []
  133. end
  134. end
  135. industry_rules.each do |rule|
  136. category = rule[:category]
  137. @rules_cache[category] ||= []
  138. @rules_cache[category] << rule
  139. end
  140. end
  141. def build_evaluator(guideline)
  142. case guideline.rule_type
  143. when "must", "do"
  144. ->(content, _context) { content_matches_positive_rule?(content, guideline) }
  145. when "must_not", "dont", "avoid"
  146. ->(content, _context) { !content_matches_negative_rule?(content, guideline) }
  147. when "should", "prefer"
  148. ->(content, _context) { content_follows_suggestion?(content, guideline) }
  149. else
  150. ->(content, _context) { true }
  151. end
  152. end
  153. def evaluate_rule(rule, content, context)
  154. begin
  155. passed = rule[:evaluator].call(content, context)
  156. {
  157. rule_id: rule[:id],
  158. status: determine_status(passed, rule),
  159. message: build_message(passed, rule),
  160. severity: determine_severity(rule),
  161. details: {
  162. rule_type: rule[:type],
  163. category: rule[:category],
  164. mandatory: rule[:mandatory]
  165. }
  166. }
  167. rescue StandardError => e
  168. Rails.logger.error "Rule evaluation error: #{e.message}"
  169. {
  170. rule_id: rule[:id],
  171. status: :error,
  172. message: "Error evaluating rule: #{rule[:content]}",
  173. severity: "low",
  174. error: e.message
  175. }
  176. end
  177. end
  178. def determine_status(passed, rule)
  179. if passed
  180. :passed
  181. elsif rule[:mandatory]
  182. :failed
  183. else
  184. :warning
  185. end
  186. end
  187. def determine_severity(rule)
  188. if rule[:mandatory]
  189. priority_to_severity(rule[:priority])
  190. else
  191. "low"
  192. end
  193. end
  194. def priority_to_severity(priority)
  195. case priority
  196. when 90..100 then "critical"
  197. when 70..89 then "high"
  198. when 50..69 then "medium"
  199. else "low"
  200. end
  201. end
  202. def calculate_priority(guideline)
  203. base_priority = guideline.priority * 10
  204. # Boost priority for mandatory rules
  205. base_priority += 20 if guideline.mandatory?
  206. # Cap at maximum
  207. [base_priority, 100].min
  208. end
  209. def filter_rules_by_context(context)
  210. all_rules = @rules_cache.values.flatten
  211. # Filter based on content type
  212. if context[:content_type].present?
  213. all_rules = all_rules.select do |rule|
  214. rule[:metadata][:content_types].blank? ||
  215. rule[:metadata][:content_types].include?(context[:content_type])
  216. end
  217. end
  218. # Filter based on channel
  219. if context[:channel].present?
  220. all_rules = all_rules.select do |rule|
  221. rule[:metadata][:channels].blank? ||
  222. rule[:metadata][:channels].include?(context[:channel])
  223. end
  224. end
  225. # Sort by priority
  226. all_rules.sort_by { |rule| -rule[:priority] }
  227. end
  228. def calculate_score(results, total_rules)
  229. return 1.0 if total_rules.empty?
  230. # Weight rules by priority
  231. total_weight = 0.0
  232. passed_weight = 0.0
  233. results[:passed].each do |result|
  234. rule = find_rule(result[:rule_id])
  235. weight = rule[:priority] / 100.0
  236. total_weight += weight
  237. passed_weight += weight
  238. end
  239. results[:failed].each do |result|
  240. rule = find_rule(result[:rule_id])
  241. weight = rule[:priority] / 100.0
  242. total_weight += weight
  243. end
  244. results[:warnings].each do |result|
  245. rule = find_rule(result[:rule_id])
  246. weight = rule[:priority] / 100.0
  247. total_weight += weight
  248. passed_weight += weight * 0.5 # Partial credit for warnings
  249. end
  250. return 0.0 if total_weight == 0
  251. (passed_weight / total_weight).round(3)
  252. end
  253. def detect_conflicts(failed_results)
  254. conflicts = []
  255. failed_results.each_with_index do |result1, i|
  256. failed_results[(i+1)..-1].each do |result2|
  257. if rules_conflict?(result1, result2)
  258. conflicts << {
  259. rule1: result1[:rule_id],
  260. rule2: result2[:rule_id],
  261. type: "contradiction",
  262. resolution: suggest_resolution(result1, result2)
  263. }
  264. end
  265. end
  266. end
  267. conflicts
  268. end
  269. def rules_conflict?(result1, result2)
  270. rule1 = find_rule(result1[:rule_id])
  271. rule2 = find_rule(result2[:rule_id])
  272. return false unless rule1 && rule2
  273. # Check for contradictory rules
  274. (rule1[:type] == "must" && rule2[:type] == "must_not") ||
  275. (rule1[:type] == "must_not" && rule2[:type] == "must")
  276. end
  277. def suggest_resolution(result1, result2)
  278. rule1 = find_rule(result1[:rule_id])
  279. rule2 = find_rule(result2[:rule_id])
  280. # Higher priority rule takes precedence
  281. if rule1[:priority] > rule2[:priority]
  282. "Follow rule #{rule1[:id]} (higher priority)"
  283. elsif rule2[:priority] > rule1[:priority]
  284. "Follow rule #{rule2[:id]} (higher priority)"
  285. else
  286. "Review both rules and update priorities"
  287. end
  288. end
  289. def find_rule(rule_id)
  290. @rules_cache.values.flatten.find { |rule| rule[:id] == rule_id }
  291. end
  292. def cache_compiled_rules
  293. Rails.cache.write(
  294. "compiled_rules:#{brand.id}",
  295. @rules_cache,
  296. expires_in: 1.hour
  297. )
  298. end
  299. # Helper methods for rule evaluation
  300. def content_matches_positive_rule?(content, guideline)
  301. keywords = extract_keywords(guideline.rule_content)
  302. content_lower = content.downcase
  303. keywords.any? { |keyword| content_lower.include?(keyword.downcase) }
  304. end
  305. def content_matches_negative_rule?(content, guideline)
  306. keywords = extract_keywords(guideline.rule_content)
  307. content_lower = content.downcase
  308. keywords.any? { |keyword| content_lower.include?(keyword.downcase) }
  309. end
  310. def content_follows_suggestion?(content, guideline)
  311. # More lenient check for suggestions
  312. keywords = extract_keywords(guideline.rule_content)
  313. content_lower = content.downcase
  314. matching_keywords = keywords.count { |keyword| content_lower.include?(keyword.downcase) }
  315. matching_keywords >= (keywords.length * 0.3) # 30% match threshold
  316. end
  317. def extract_keywords(text)
  318. stop_words = %w[the a an and or but in on at to for of with as by that which who whom whose when where why how]
  319. text.downcase
  320. .split(/\W+/)
  321. .reject { |word| stop_words.include?(word) || word.length < 3 }
  322. .uniq
  323. end
  324. def contains_profanity?(content)
  325. # Implement profanity detection
  326. profanity_list = Rails.cache.fetch("profanity_list", expires_in: 1.day) do
  327. # Load from database or external service
  328. %w[badword1 badword2] # Placeholder
  329. end
  330. content_lower = content.downcase
  331. profanity_list.any? { |word| content_lower.include?(word) }
  332. end
  333. def check_legal_requirements(content, context)
  334. # Check for required legal disclaimers based on context
  335. true # Placeholder
  336. end
  337. def check_accessibility(content, context)
  338. # Check accessibility guidelines
  339. true # Placeholder
  340. end
  341. def build_message(passed, rule)
  342. if passed
  343. "Complies with: #{rule[:content]}"
  344. else
  345. "Violates: #{rule[:content]}"
  346. end
  347. end
  348. # Industry-specific rule loaders
  349. def load_healthcare_rules
  350. [
  351. {
  352. id: "healthcare_hipaa",
  353. category: "legal",
  354. type: "must_not",
  355. content: "Must not disclose protected health information",
  356. priority: RULE_PRIORITIES[:critical],
  357. mandatory: true,
  358. evaluator: ->(content, _context) { !contains_phi?(content) }
  359. }
  360. ]
  361. end
  362. def load_finance_rules
  363. [
  364. {
  365. id: "finance_disclaimer",
  366. category: "legal",
  367. type: "must",
  368. content: "Must include investment risk disclaimer",
  369. priority: RULE_PRIORITIES[:critical],
  370. mandatory: true,
  371. evaluator: ->(content, context) { contains_required_disclaimer?(content, context) }
  372. }
  373. ]
  374. end
  375. def load_technology_rules
  376. [
  377. {
  378. id: "tech_accuracy",
  379. category: "content",
  380. type: "must",
  381. content: "Technical specifications must be accurate",
  382. priority: RULE_PRIORITIES[:high],
  383. mandatory: true,
  384. evaluator: ->(content, _context) { validate_technical_accuracy(content) }
  385. }
  386. ]
  387. end
  388. def contains_phi?(content)
  389. # Check for protected health information patterns
  390. false # Placeholder
  391. end
  392. def contains_required_disclaimer?(content, context)
  393. # Check for required disclaimers
  394. true # Placeholder
  395. end
  396. def validate_technical_accuracy(content)
  397. # Validate technical claims
  398. true # Placeholder
  399. end
  400. end
  401. end
  402. end

app/services/branding/compliance/suggestion_engine.rb

0.0% lines covered

742 relevant lines. 0 lines covered and 742 lines missed.
    
  1. module Branding
  2. module Compliance
  3. class SuggestionEngine
  4. attr_reader :brand, :violations, :analysis_results
  5. def initialize(brand, violations, analysis_results = {})
  6. @brand = brand
  7. @violations = violations
  8. @analysis_results = analysis_results
  9. @llm_service = LlmService.new
  10. end
  11. def generate_suggestions
  12. suggestions = []
  13. # Group violations by type for pattern analysis
  14. grouped_violations = group_violations
  15. # Generate contextual suggestions for each violation type
  16. grouped_violations.each do |type, type_violations|
  17. suggestions.concat(generate_suggestions_for_type(type, type_violations))
  18. end
  19. # Add proactive improvements based on analysis
  20. suggestions.concat(generate_proactive_suggestions)
  21. # Prioritize and deduplicate suggestions
  22. prioritized_suggestions = prioritize_suggestions(suggestions)
  23. # Generate implementation guidance
  24. add_implementation_guidance(prioritized_suggestions)
  25. end
  26. def generate_fix(violation, content)
  27. case violation[:type]
  28. when "banned_words"
  29. fix_banned_words(violation, content)
  30. when "tone_mismatch"
  31. fix_tone_mismatch(violation, content)
  32. when "missing_required_element"
  33. fix_missing_element(violation, content)
  34. when "readability_mismatch"
  35. fix_readability(violation, content)
  36. else
  37. generate_ai_fix(violation, content)
  38. end
  39. end
  40. def suggest_alternatives(phrase, context = {})
  41. prompt = build_alternatives_prompt(phrase, context)
  42. response = @llm_service.analyze(prompt, {
  43. json_response: true,
  44. temperature: 0.7,
  45. max_tokens: 500
  46. })
  47. parse_alternatives_response(response)
  48. end
  49. private
  50. def group_violations
  51. violations.group_by { |v| v[:type] }
  52. end
  53. def generate_suggestions_for_type(type, type_violations)
  54. case type
  55. when "tone_mismatch"
  56. generate_tone_suggestions(type_violations)
  57. when "banned_words"
  58. generate_vocabulary_suggestions(type_violations)
  59. when "missing_required_element"
  60. generate_element_suggestions(type_violations)
  61. when "readability_mismatch"
  62. generate_readability_suggestions(type_violations)
  63. when "brand_voice_misalignment"
  64. generate_voice_suggestions(type_violations)
  65. when "color_violation"
  66. generate_color_suggestions(type_violations)
  67. when "typography_violation"
  68. generate_typography_suggestions(type_violations)
  69. else
  70. generate_generic_suggestions(type_violations)
  71. end
  72. end
  73. def generate_tone_suggestions(violations)
  74. suggestions = []
  75. # Analyze the pattern of tone issues
  76. expected_tones = violations.map { |v| v[:details][:expected] }.uniq
  77. detected_tones = violations.map { |v| v[:details][:detected] }.uniq
  78. if expected_tones.length == 1
  79. target_tone = expected_tones.first
  80. suggestions << {
  81. type: "tone_adjustment",
  82. priority: "high",
  83. title: "Align content tone with brand voice",
  84. description: "Adjust the overall tone to be more #{target_tone}",
  85. specific_actions: generate_tone_actions(target_tone, detected_tones),
  86. examples: generate_tone_examples(target_tone),
  87. effort_level: "medium"
  88. }
  89. end
  90. suggestions
  91. end
  92. def generate_tone_actions(target_tone, current_tones)
  93. actions = []
  94. tone_adjustments = {
  95. "professional" => {
  96. "casual" => ["Replace contractions with full forms", "Use more formal vocabulary", "Structure sentences more formally"],
  97. "friendly" => ["Maintain warmth while adding authority", "Use industry terminology appropriately"]
  98. },
  99. "friendly" => {
  100. "formal" => ["Use conversational language", "Add personal pronouns", "Include relatable examples"],
  101. "professional" => ["Soften technical language", "Add warmth to explanations"]
  102. },
  103. "casual" => {
  104. "formal" => ["Use contractions where appropriate", "Simplify complex sentences", "Add colloquialisms"],
  105. "professional" => ["Relax the tone while maintaining credibility", "Use everyday language"]
  106. }
  107. }
  108. current_tones.each do |current|
  109. if tone_adjustments[target_tone] && tone_adjustments[target_tone][current]
  110. actions.concat(tone_adjustments[target_tone][current])
  111. end
  112. end
  113. actions.uniq
  114. end
  115. def generate_tone_examples(target_tone)
  116. examples = {
  117. "professional" => [
  118. { before: "We're gonna help you out!", after: "We will assist you with your needs." },
  119. { before: "Check this out!", after: "Please review the following information." }
  120. ],
  121. "friendly" => [
  122. { before: "The user must complete the form.", after: "You'll need to fill out a quick form." },
  123. { before: "This is required.", after: "We'll need this from you." }
  124. ],
  125. "casual" => [
  126. { before: "We are pleased to announce", after: "Hey, we've got some great news" },
  127. { before: "Please be advised", after: "Just wanted to let you know" }
  128. ]
  129. }
  130. examples[target_tone] || []
  131. end
  132. def generate_vocabulary_suggestions(violations)
  133. suggestions = []
  134. banned_words = violations.flat_map { |v| v[:details] }.uniq
  135. suggestions << {
  136. type: "vocabulary_replacement",
  137. priority: "critical",
  138. title: "Replace prohibited terminology",
  139. description: "Remove or replace words that conflict with brand guidelines",
  140. specific_actions: [
  141. "Review and replace all instances of banned words",
  142. "Update content to use approved brand terminology",
  143. "Create a glossary of preferred alternatives"
  144. ],
  145. word_replacements: generate_word_replacements(banned_words),
  146. effort_level: "low"
  147. }
  148. suggestions
  149. end
  150. def generate_word_replacements(banned_words)
  151. replacements = {}
  152. # Get brand-specific alternatives
  153. messaging_framework = brand.messaging_framework
  154. preferred_terms = messaging_framework&.metadata&.dig("preferred_terms") || {}
  155. banned_words.each do |word|
  156. replacements[word] = find_alternatives_for_word(word, preferred_terms)
  157. end
  158. replacements
  159. end
  160. def find_alternatives_for_word(word, preferred_terms)
  161. # Check if we have a direct mapping
  162. return preferred_terms[word] if preferred_terms[word]
  163. # Generate contextual alternatives
  164. common_replacements = {
  165. "cheap" => ["affordable", "value-priced", "economical"],
  166. "expensive" => ["premium", "investment", "high-value"],
  167. "problem" => ["challenge", "opportunity", "situation"],
  168. "failure" => ["learning experience", "setback", "area for improvement"]
  169. }
  170. common_replacements[word.downcase] || ["[Review context for appropriate alternative]"]
  171. end
  172. def generate_element_suggestions(violations)
  173. suggestions = []
  174. missing_elements = violations.map { |v| v[:details][:category] }.uniq
  175. suggestions << {
  176. type: "content_addition",
  177. priority: "high",
  178. title: "Add required brand elements",
  179. description: "Include mandatory elements missing from the content",
  180. specific_actions: missing_elements.map { |element| "Add #{element}" },
  181. templates: generate_element_templates(missing_elements),
  182. effort_level: "medium"
  183. }
  184. suggestions
  185. end
  186. def generate_element_templates(elements)
  187. templates = {}
  188. element_mappings = {
  189. "tagline" => brand.messaging_framework&.taglines&.dig("primary"),
  190. "disclaimer" => brand.brand_guidelines.by_category("legal").first&.rule_content,
  191. "contact" => generate_contact_template,
  192. "cta" => generate_cta_template
  193. }
  194. elements.each do |element|
  195. templates[element] = element_mappings[element] || "[Custom content required]"
  196. end
  197. templates
  198. end
  199. def generate_readability_suggestions(violations)
  200. suggestions = []
  201. readability_issues = violations.first[:details]
  202. current_grade = readability_issues[:current_grade]
  203. target_grade = readability_issues[:target_grade]
  204. if current_grade > target_grade
  205. suggestions << {
  206. type: "simplification",
  207. priority: "medium",
  208. title: "Simplify content for target audience",
  209. description: "Reduce complexity to match reading level #{target_grade}",
  210. specific_actions: [
  211. "Shorten sentences (aim for 15-20 words average)",
  212. "Replace complex words with simpler alternatives",
  213. "Break up long paragraphs",
  214. "Use active voice",
  215. "Add subheadings for better scanning"
  216. ],
  217. examples: generate_simplification_examples,
  218. effort_level: "high"
  219. }
  220. else
  221. suggestions << {
  222. type: "sophistication",
  223. priority: "medium",
  224. title: "Enhance content sophistication",
  225. description: "Increase complexity to match reading level #{target_grade}",
  226. specific_actions: [
  227. "Use more varied sentence structures",
  228. "Incorporate industry-specific terminology",
  229. "Add nuanced explanations",
  230. "Develop ideas more thoroughly"
  231. ],
  232. effort_level: "medium"
  233. }
  234. end
  235. suggestions
  236. end
  237. def generate_simplification_examples
  238. [
  239. {
  240. before: "The implementation of our comprehensive solution necessitates a thorough evaluation of existing infrastructure.",
  241. after: "To use our solution, we need to review your current setup."
  242. },
  243. {
  244. before: "Utilize this functionality to optimize your workflow efficiency.",
  245. after: "Use this feature to work faster."
  246. }
  247. ]
  248. end
  249. def generate_voice_suggestions(violations)
  250. suggestions = []
  251. alignment_score = violations.first[:details][:alignment_score]
  252. missing_elements = violations.first[:details][:missing_elements] || []
  253. suggestions << {
  254. type: "brand_voice_alignment",
  255. priority: "high",
  256. title: "Strengthen brand voice consistency",
  257. description: "Align content more closely with established brand personality",
  258. specific_actions: [
  259. "Incorporate brand personality traits throughout",
  260. "Use brand-specific phrases and expressions",
  261. "Mirror the brand's communication style",
  262. "Include brand storytelling elements"
  263. ],
  264. voice_checklist: generate_voice_checklist,
  265. missing_elements: missing_elements,
  266. effort_level: "high"
  267. }
  268. suggestions
  269. end
  270. def generate_voice_checklist
  271. voice_attributes = brand.brand_voice_attributes
  272. checklist = []
  273. voice_attributes.each do |category, attributes|
  274. attributes.each do |key, value|
  275. checklist << {
  276. attribute: "#{category}.#{key}",
  277. target: value,
  278. check: "Does the content reflect #{value}?"
  279. }
  280. end
  281. end
  282. checklist
  283. end
  284. def generate_color_suggestions(violations)
  285. suggestions = []
  286. non_compliant_colors = violations.flat_map { |v| v[:details][:non_compliant_colors] }.uniq
  287. suggestions << {
  288. type: "color_correction",
  289. priority: "high",
  290. title: "Align colors with brand palette",
  291. description: "Replace non-brand colors with approved alternatives",
  292. specific_actions: [
  293. "Update all color values to match brand guidelines",
  294. "Ensure proper color usage hierarchy",
  295. "Maintain color consistency across all elements"
  296. ],
  297. color_mappings: generate_color_mappings(non_compliant_colors),
  298. effort_level: "low"
  299. }
  300. suggestions
  301. end
  302. def generate_color_mappings(non_compliant_colors)
  303. mappings = {}
  304. brand_colors = brand.primary_colors + brand.secondary_colors
  305. non_compliant_colors.each do |color|
  306. mappings[color] = find_closest_brand_color(color, brand_colors)
  307. end
  308. mappings
  309. end
  310. def find_closest_brand_color(color, brand_colors)
  311. return brand_colors.first if brand_colors.empty?
  312. # Find the brand color with minimum color distance
  313. closest = brand_colors.min_by do |brand_color|
  314. color_distance(color, brand_color)
  315. end
  316. {
  317. color: closest,
  318. distance: color_distance(color, closest).round(2)
  319. }
  320. end
  321. def color_distance(color1, color2)
  322. # Simplified - would use proper color distance calculation
  323. 0.0
  324. end
  325. def generate_typography_suggestions(violations)
  326. suggestions = []
  327. non_compliant_fonts = violations.flat_map { |v| v[:details][:non_compliant_fonts] }.uniq
  328. suggestions << {
  329. type: "typography_alignment",
  330. priority: "medium",
  331. title: "Update typography to brand standards",
  332. description: "Use only approved brand fonts",
  333. specific_actions: [
  334. "Replace non-brand fonts with approved alternatives",
  335. "Ensure proper font hierarchy",
  336. "Apply consistent font sizing and spacing"
  337. ],
  338. font_mappings: generate_font_mappings(non_compliant_fonts),
  339. effort_level: "medium"
  340. }
  341. suggestions
  342. end
  343. def generate_font_mappings(non_compliant_fonts)
  344. mappings = {}
  345. brand_fonts = brand.font_families
  346. non_compliant_fonts.each do |font|
  347. mappings[font] = suggest_brand_font(font, brand_fonts)
  348. end
  349. mappings
  350. end
  351. def suggest_brand_font(font, brand_fonts)
  352. # Map common fonts to brand alternatives
  353. font_categories = {
  354. serif: ["Georgia", "Times New Roman", "Garamond"],
  355. sans_serif: ["Arial", "Helvetica", "Verdana"],
  356. monospace: ["Courier", "Consolas", "Monaco"]
  357. }
  358. # Determine font category
  359. category = font_categories.find { |_, fonts| fonts.include?(font) }&.first || :sans_serif
  360. # Return appropriate brand font
  361. brand_fonts[category.to_s] || brand_fonts["primary"] || "Use primary brand font"
  362. end
  363. def generate_generic_suggestions(violations)
  364. violations.map do |violation|
  365. {
  366. type: "compliance_fix",
  367. priority: violation[:severity],
  368. title: "Address: #{violation[:message]}",
  369. description: "Fix compliance issue",
  370. specific_actions: ["Review and correct the identified issue"],
  371. effort_level: "medium"
  372. }
  373. end
  374. end
  375. def generate_proactive_suggestions
  376. suggestions = []
  377. # Based on analysis results, suggest improvements
  378. if analysis_results[:nlp_analysis]
  379. suggestions.concat(generate_nlp_based_suggestions)
  380. end
  381. if analysis_results[:visual_analysis]
  382. suggestions.concat(generate_visual_based_suggestions)
  383. end
  384. suggestions
  385. end
  386. def generate_nlp_based_suggestions
  387. suggestions = []
  388. nlp = analysis_results[:nlp_analysis]
  389. # Suggest improvements based on scores
  390. if nlp[:tone][:confidence] < 0.8
  391. suggestions << {
  392. type: "tone_strengthening",
  393. priority: "low",
  394. title: "Strengthen brand tone consistency",
  395. description: "Make the brand tone more prominent throughout the content",
  396. specific_actions: [
  397. "Use more characteristic brand expressions",
  398. "Maintain consistent tone throughout all sections",
  399. "Avoid tone shifts mid-content"
  400. ],
  401. effort_level: "medium"
  402. }
  403. end
  404. if nlp[:keyword_density]
  405. low_density_keywords = nlp[:keyword_density][:keyword_densities].select do |_, data|
  406. data[:density] < data[:optimal_range][:min]
  407. end
  408. if low_density_keywords.any?
  409. suggestions << {
  410. type: "keyword_optimization",
  411. priority: "low",
  412. title: "Optimize keyword usage",
  413. description: "Increase usage of important brand keywords",
  414. keywords_to_increase: low_density_keywords.keys,
  415. effort_level: "low"
  416. }
  417. end
  418. end
  419. suggestions
  420. end
  421. def generate_visual_based_suggestions
  422. suggestions = []
  423. # Add visual-specific proactive suggestions
  424. suggestions
  425. end
  426. def prioritize_suggestions(suggestions)
  427. # Define priority weights
  428. priority_weights = {
  429. "critical" => 1000,
  430. "high" => 100,
  431. "medium" => 10,
  432. "low" => 1
  433. }
  434. # Sort by priority weight
  435. sorted = suggestions.sort_by do |suggestion|
  436. -priority_weights[suggestion[:priority]]
  437. end
  438. # Remove duplicates while preserving order
  439. sorted.uniq { |s| [s[:type], s[:title]] }
  440. end
  441. def add_implementation_guidance(suggestions)
  442. suggestions.map do |suggestion|
  443. suggestion[:implementation_guide] = generate_implementation_guide(suggestion)
  444. suggestion[:estimated_time] = estimate_implementation_time(suggestion)
  445. suggestion[:automation_possible] = can_automate?(suggestion)
  446. if suggestion[:automation_possible]
  447. suggestion[:automation_script] = generate_automation_script(suggestion)
  448. end
  449. suggestion
  450. end
  451. end
  452. def generate_implementation_guide(suggestion)
  453. case suggestion[:type]
  454. when "tone_adjustment"
  455. generate_tone_implementation_guide(suggestion)
  456. when "vocabulary_replacement"
  457. generate_vocabulary_implementation_guide(suggestion)
  458. when "content_addition"
  459. generate_content_implementation_guide(suggestion)
  460. else
  461. generate_generic_implementation_guide(suggestion)
  462. end
  463. end
  464. def generate_tone_implementation_guide(suggestion)
  465. {
  466. steps: [
  467. "Review current content tone using the provided examples",
  468. "Identify sections that need adjustment",
  469. "Apply the specific actions listed",
  470. "Read through the entire content to ensure consistency",
  471. "Test with sample audience if possible"
  472. ],
  473. tools: ["Grammar checker", "Readability analyzer", "Brand voice guide"],
  474. checkpoints: [
  475. "All contractions addressed (if formalizing)",
  476. "Vocabulary matches target tone",
  477. "Sentence structure aligns with tone",
  478. "Overall feel matches brand voice"
  479. ]
  480. }
  481. end
  482. def generate_vocabulary_implementation_guide(suggestion)
  483. {
  484. steps: [
  485. "Use find-and-replace for each banned word",
  486. "Review context for each replacement",
  487. "Ensure replacements maintain sentence flow",
  488. "Update any related phrases or variations",
  489. "Document replacements for future reference"
  490. ],
  491. tools: ["Text editor with find-replace", "Brand terminology guide"],
  492. checkpoints: [
  493. "All banned words replaced",
  494. "Replacements fit context",
  495. "Content still reads naturally",
  496. "Brand voice maintained"
  497. ]
  498. }
  499. end
  500. def generate_content_implementation_guide(suggestion)
  501. {
  502. steps: [
  503. "Locate appropriate positions for missing elements",
  504. "Use provided templates as starting points",
  505. "Customize templates to fit content context",
  506. "Ensure smooth integration with existing content",
  507. "Verify all required elements are included"
  508. ],
  509. tools: ["Brand element templates", "Content guidelines"],
  510. checkpoints: [
  511. "All required elements present",
  512. "Elements properly formatted",
  513. "Natural integration achieved",
  514. "Brand consistency maintained"
  515. ]
  516. }
  517. end
  518. def generate_generic_implementation_guide(suggestion)
  519. {
  520. steps: suggestion[:specific_actions],
  521. tools: ["Brand guidelines", "Style guide"],
  522. checkpoints: ["Issue resolved", "Brand compliance achieved"]
  523. }
  524. end
  525. def estimate_implementation_time(suggestion)
  526. base_times = {
  527. "low" => 15,
  528. "medium" => 45,
  529. "high" => 120
  530. }
  531. base_time = base_times[suggestion[:effort_level]] || 30
  532. # Adjust based on specific factors
  533. if suggestion[:specific_actions].length > 5
  534. base_time *= 1.5
  535. end
  536. if suggestion[:automation_possible]
  537. base_time *= 0.3
  538. end
  539. {
  540. minutes: base_time.round,
  541. human_readable: format_time(base_time)
  542. }
  543. end
  544. def format_time(minutes)
  545. if minutes < 60
  546. "#{minutes.round} minutes"
  547. else
  548. hours = (minutes / 60.0).round(1)
  549. "#{hours} hours"
  550. end
  551. end
  552. def can_automate?(suggestion)
  553. automatable_types = [
  554. "vocabulary_replacement",
  555. "color_correction",
  556. "typography_alignment"
  557. ]
  558. automatable_types.include?(suggestion[:type])
  559. end
  560. def generate_automation_script(suggestion)
  561. case suggestion[:type]
  562. when "vocabulary_replacement"
  563. generate_replacement_script(suggestion)
  564. when "color_correction"
  565. generate_color_script(suggestion)
  566. when "typography_alignment"
  567. generate_typography_script(suggestion)
  568. else
  569. nil
  570. end
  571. end
  572. def generate_replacement_script(suggestion)
  573. replacements = suggestion[:word_replacements]
  574. {
  575. type: "text_replacement",
  576. description: "Automated word replacement script",
  577. script: replacements.map do |word, alternatives|
  578. {
  579. find: word,
  580. replace: alternatives.first,
  581. case_sensitive: false,
  582. whole_word: true
  583. }
  584. end
  585. }
  586. end
  587. def generate_color_script(suggestion)
  588. mappings = suggestion[:color_mappings]
  589. {
  590. type: "css_replacement",
  591. description: "Automated color replacement for CSS",
  592. script: mappings.map do |old_color, new_color_data|
  593. {
  594. find: old_color,
  595. replace: new_color_data[:color],
  596. contexts: ["css", "style attributes"]
  597. }
  598. end
  599. }
  600. end
  601. def generate_typography_script(suggestion)
  602. mappings = suggestion[:font_mappings]
  603. {
  604. type: "font_replacement",
  605. description: "Automated font replacement",
  606. script: mappings.map do |old_font, new_font|
  607. {
  608. find: old_font,
  609. replace: new_font,
  610. preserve_weight: true,
  611. preserve_style: true
  612. }
  613. end
  614. }
  615. end
  616. # Fix generation methods
  617. def fix_banned_words(violation, content)
  618. banned_words = violation[:details]
  619. replacements = generate_word_replacements(banned_words)
  620. fixed_content = content.dup
  621. replacements.each do |word, alternatives|
  622. regex = /\b#{Regexp.escape(word)}\b/i
  623. fixed_content.gsub!(regex, alternatives.first)
  624. end
  625. {
  626. fixed_content: fixed_content,
  627. changes_made: replacements,
  628. confidence: 0.9
  629. }
  630. end
  631. def fix_tone_mismatch(violation, content)
  632. expected_tone = violation[:details][:expected]
  633. prompt = build_tone_fix_prompt(content, expected_tone)
  634. response = @llm_service.analyze(prompt, {
  635. temperature: 0.5,
  636. max_tokens: content.length + 500
  637. })
  638. {
  639. fixed_content: response,
  640. changes_made: ["Adjusted tone to be more #{expected_tone}"],
  641. confidence: 0.7
  642. }
  643. end
  644. def fix_missing_element(violation, content)
  645. missing_element = violation[:details][:category]
  646. template = generate_element_templates([missing_element])[missing_element]
  647. # Determine where to add the element
  648. if missing_element == "disclaimer" || missing_element == "footer"
  649. fixed_content = "#{content}\n\n#{template}"
  650. else
  651. fixed_content = "#{template}\n\n#{content}"
  652. end
  653. {
  654. fixed_content: fixed_content,
  655. changes_made: ["Added required #{missing_element}"],
  656. confidence: 0.8
  657. }
  658. end
  659. def fix_readability(violation, content)
  660. current_grade = violation[:details][:current_grade]
  661. target_grade = violation[:details][:target_grade]
  662. prompt = build_readability_fix_prompt(content, current_grade, target_grade)
  663. response = @llm_service.analyze(prompt, {
  664. temperature: 0.3,
  665. max_tokens: content.length + 500
  666. })
  667. {
  668. fixed_content: response,
  669. changes_made: ["Adjusted readability from grade #{current_grade} to #{target_grade}"],
  670. confidence: 0.6
  671. }
  672. end
  673. def generate_ai_fix(violation, content)
  674. prompt = build_generic_fix_prompt(violation, content)
  675. response = @llm_service.analyze(prompt, {
  676. temperature: 0.4,
  677. max_tokens: content.length + 500
  678. })
  679. {
  680. fixed_content: response,
  681. changes_made: ["Applied AI-generated fix for #{violation[:type]}"],
  682. confidence: 0.5
  683. }
  684. end
  685. # Prompt builders
  686. def build_alternatives_prompt(phrase, context)
  687. brand_voice = brand.brand_voice_attributes
  688. <<~PROMPT
  689. Generate alternative phrasings for: "#{phrase}"
  690. Context:
  691. Content Type: #{context[:content_type]}
  692. Target Audience: #{context[:audience]}
  693. Brand Voice: #{brand_voice.to_json}
  694. Provide 3-5 alternatives that:
  695. 1. Maintain the same meaning
  696. 2. Align with brand voice
  697. 3. Fit the context
  698. 4. Vary in style/approach
  699. Format as JSON:
  700. {
  701. "alternatives": [
  702. {
  703. "text": "alternative phrase",
  704. "style": "formal|casual|technical|friendly",
  705. "best_for": "situation where this works best"
  706. }
  707. ]
  708. }
  709. PROMPT
  710. end
  711. def build_tone_fix_prompt(content, target_tone)
  712. <<~PROMPT
  713. Rewrite the following content to have a #{target_tone} tone:
  714. #{content}
  715. Guidelines:
  716. - Maintain all factual information
  717. - Keep the same structure and flow
  718. - Adjust vocabulary and sentence structure
  719. - Ensure consistent #{target_tone} tone throughout
  720. Return only the rewritten content.
  721. PROMPT
  722. end
  723. def build_readability_fix_prompt(content, current_grade, target_grade)
  724. direction = current_grade > target_grade ? "simplify" : "sophisticate"
  725. <<~PROMPT
  726. #{direction.capitalize} the following content from grade level #{current_grade} to #{target_grade}:
  727. #{content}
  728. Guidelines:
  729. - Maintain all key information
  730. - #{direction == "simplify" ? "Use shorter sentences and simpler words" : "Use more complex sentence structures and vocabulary"}
  731. - Keep the same overall message
  732. - Ensure natural flow
  733. Return only the adjusted content.
  734. PROMPT
  735. end
  736. def build_generic_fix_prompt(violation, content)
  737. <<~PROMPT
  738. Fix the following compliance issue in the content:
  739. Issue: #{violation[:message]}
  740. Type: #{violation[:type]}
  741. Details: #{violation[:details].to_json}
  742. Content:
  743. #{content}
  744. Guidelines:
  745. - Address the specific issue identified
  746. - Maintain content meaning and flow
  747. - Follow brand guidelines
  748. - Make minimal necessary changes
  749. Return only the fixed content.
  750. PROMPT
  751. end
  752. def parse_alternatives_response(response)
  753. return [] unless response
  754. begin
  755. parsed = JSON.parse(response, symbolize_names: true)
  756. parsed[:alternatives] || []
  757. rescue JSON::ParserError
  758. []
  759. end
  760. end
  761. def generate_contact_template
  762. "Contact us at [email] or call [phone]"
  763. end
  764. def generate_cta_template
  765. primary_cta = brand.messaging_framework&.metadata&.dig("primary_cta") || "Learn More"
  766. "#{primary_cta} →"
  767. end
  768. end
  769. end
  770. end

app/services/branding/compliance/visual_validator.rb

0.0% lines covered

560 relevant lines. 0 lines covered and 560 lines missed.
    
  1. module Branding
  2. module Compliance
  3. class VisualValidator < BaseValidator
  4. SUPPORTED_FORMATS = %w[image/jpeg image/png image/gif image/webp image/svg+xml].freeze
  5. COLOR_TOLERANCE = 15 # Delta E tolerance for color matching
  6. def initialize(brand, content, options = {})
  7. super
  8. @visual_data = options[:visual_data] || {}
  9. @llm_service = options[:llm_service] || LlmService.new
  10. end
  11. def validate
  12. return unless visual_content?
  13. # Validate colors
  14. check_color_compliance
  15. # Validate typography (if text is present)
  16. check_typography_compliance
  17. # Validate logo usage
  18. check_logo_compliance
  19. # Validate composition and layout
  20. check_composition_compliance
  21. # Validate image quality
  22. check_quality_standards
  23. # Check accessibility
  24. check_visual_accessibility
  25. { violations: @violations, suggestions: @suggestions }
  26. end
  27. def analyze_image(image_data)
  28. cached_result("visual_analysis:#{image_data[:id]}") do
  29. prompt = build_visual_analysis_prompt(image_data)
  30. response = @llm_service.analyze(prompt, {
  31. json_response: true,
  32. temperature: 0.3,
  33. system_message: "You are an expert visual brand compliance analyst."
  34. })
  35. parse_json_response(response)
  36. end
  37. end
  38. private
  39. def visual_content?
  40. @visual_data.present? || content_type_visual?
  41. end
  42. def content_type_visual?
  43. return false unless options[:content_type]
  44. %w[image video infographic logo banner].include?(options[:content_type])
  45. end
  46. def check_color_compliance
  47. return unless @visual_data[:colors].present?
  48. detected_colors = @visual_data[:colors]
  49. brand_colors = {
  50. primary: brand.primary_colors,
  51. secondary: brand.secondary_colors
  52. }
  53. # Check primary color usage
  54. primary_compliant = check_color_set_compliance(
  55. detected_colors[:primary] || [],
  56. brand_colors[:primary],
  57. "primary"
  58. )
  59. # Check secondary color usage
  60. secondary_compliant = check_color_set_compliance(
  61. detected_colors[:secondary] || [],
  62. brand_colors[:secondary],
  63. "secondary"
  64. )
  65. # Check color harmony
  66. check_color_harmony(detected_colors)
  67. # Check brand color dominance
  68. check_brand_color_dominance(detected_colors, brand_colors)
  69. end
  70. def check_color_set_compliance(detected_colors, brand_colors, color_type)
  71. return true if brand_colors.empty?
  72. non_compliant_colors = []
  73. detected_colors.each do |detected|
  74. unless color_matches_any?(detected, brand_colors)
  75. non_compliant_colors << detected
  76. end
  77. end
  78. if non_compliant_colors.any?
  79. add_violation(
  80. type: "color_violation",
  81. severity: color_type == "primary" ? "high" : "medium",
  82. message: "Non-brand #{color_type} colors detected",
  83. details: {
  84. non_compliant_colors: non_compliant_colors,
  85. expected_colors: brand_colors,
  86. color_type: color_type
  87. }
  88. )
  89. false
  90. else
  91. true
  92. end
  93. end
  94. def color_matches_any?(color, color_set)
  95. color_set.any? do |brand_color|
  96. color_distance(color, brand_color) <= COLOR_TOLERANCE
  97. end
  98. end
  99. def color_distance(color1, color2)
  100. # Calculate Delta E (CIE76) color distance
  101. lab1 = rgb_to_lab(parse_color(color1))
  102. lab2 = rgb_to_lab(parse_color(color2))
  103. Math.sqrt(
  104. (lab2[:l] - lab1[:l]) ** 2 +
  105. (lab2[:a] - lab1[:a]) ** 2 +
  106. (lab2[:b] - lab1[:b]) ** 2
  107. )
  108. end
  109. def parse_color(color)
  110. if color.start_with?('#')
  111. # Hex color
  112. hex = color.delete('#')
  113. {
  114. r: hex[0..1].to_i(16),
  115. g: hex[2..3].to_i(16),
  116. b: hex[4..5].to_i(16)
  117. }
  118. elsif color.start_with?('rgb')
  119. # RGB color
  120. matches = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/)
  121. {
  122. r: matches[1].to_i,
  123. g: matches[2].to_i,
  124. b: matches[3].to_i
  125. }
  126. else
  127. # Named color - would need a lookup table
  128. { r: 0, g: 0, b: 0 }
  129. end
  130. end
  131. def rgb_to_lab(rgb)
  132. # Convert RGB to XYZ
  133. r = rgb[:r] / 255.0
  134. g = rgb[:g] / 255.0
  135. b = rgb[:b] / 255.0
  136. # Gamma correction
  137. r = r > 0.04045 ? ((r + 0.055) / 1.055) ** 2.4 : r / 12.92
  138. g = g > 0.04045 ? ((g + 0.055) / 1.055) ** 2.4 : g / 12.92
  139. b = b > 0.04045 ? ((b + 0.055) / 1.055) ** 2.4 : b / 12.92
  140. # Observer = 2°, Illuminant = D65
  141. x = (r * 0.4124 + g * 0.3576 + b * 0.1805) * 100
  142. y = (r * 0.2126 + g * 0.7152 + b * 0.0722) * 100
  143. z = (r * 0.0193 + g * 0.1192 + b * 0.9505) * 100
  144. # Convert XYZ to Lab
  145. x = x / 95.047
  146. y = y / 100.000
  147. z = z / 108.883
  148. x = x > 0.008856 ? x ** (1.0/3.0) : (7.787 * x + 16.0/116.0)
  149. y = y > 0.008856 ? y ** (1.0/3.0) : (7.787 * y + 16.0/116.0)
  150. z = z > 0.008856 ? z ** (1.0/3.0) : (7.787 * z + 16.0/116.0)
  151. {
  152. l: (116 * y) - 16,
  153. a: 500 * (x - y),
  154. b: 200 * (y - z)
  155. }
  156. end
  157. def check_color_harmony(detected_colors)
  158. all_colors = (detected_colors[:primary] || []) + (detected_colors[:secondary] || [])
  159. return if all_colors.length < 2
  160. # Check for clashing colors
  161. clashing_pairs = []
  162. all_colors.combination(2).each do |color1, color2|
  163. if colors_clash?(color1, color2)
  164. clashing_pairs << [color1, color2]
  165. end
  166. end
  167. if clashing_pairs.any?
  168. add_violation(
  169. type: "color_harmony",
  170. severity: "low",
  171. message: "Color combinations may clash",
  172. details: {
  173. clashing_pairs: clashing_pairs,
  174. suggestion: "Consider adjusting color combinations for better harmony"
  175. }
  176. )
  177. end
  178. end
  179. def colors_clash?(color1, color2)
  180. # Simplified clash detection based on complementary colors
  181. lab1 = rgb_to_lab(parse_color(color1))
  182. lab2 = rgb_to_lab(parse_color(color2))
  183. # Check if colors are too similar (muddy) or complementary (potentially clashing)
  184. distance = color_distance(color1, color2)
  185. # Too similar but not identical
  186. (distance > 5 && distance < 20) ||
  187. # Complementary colors with high saturation
  188. (complementary_colors?(lab1, lab2) && high_saturation?(lab1) && high_saturation?(lab2))
  189. end
  190. def complementary_colors?(lab1, lab2)
  191. # Check if colors are roughly complementary
  192. hue_diff = (Math.atan2(lab1[:b], lab1[:a]) - Math.atan2(lab2[:b], lab2[:a])).abs
  193. hue_diff = hue_diff * 180 / Math::PI
  194. hue_diff > 150 && hue_diff < 210
  195. end
  196. def high_saturation?(lab)
  197. # Calculate chroma (saturation in Lab space)
  198. Math.sqrt(lab[:a] ** 2 + lab[:b] ** 2) > 50
  199. end
  200. def check_brand_color_dominance(detected_colors, brand_colors)
  201. return unless @visual_data[:color_percentages]
  202. brand_color_percentage = calculate_brand_color_percentage(
  203. detected_colors,
  204. brand_colors
  205. )
  206. if brand_color_percentage < 60
  207. add_violation(
  208. type: "brand_color_dominance",
  209. severity: "medium",
  210. message: "Brand colors not dominant enough",
  211. details: {
  212. brand_color_percentage: brand_color_percentage,
  213. recommendation: "Brand colors should comprise at least 60% of the visual"
  214. }
  215. )
  216. elsif brand_color_percentage < 70
  217. add_suggestion(
  218. type: "brand_color_enhancement",
  219. message: "Consider increasing brand color prominence",
  220. details: {
  221. current_percentage: brand_color_percentage,
  222. target_percentage: 70
  223. }
  224. )
  225. end
  226. end
  227. def calculate_brand_color_percentage(detected_colors, brand_colors)
  228. total_percentage = 0
  229. all_brand_colors = brand_colors[:primary] + brand_colors[:secondary]
  230. @visual_data[:color_percentages].each do |color, percentage|
  231. if color_matches_any?(color, all_brand_colors)
  232. total_percentage += percentage
  233. end
  234. end
  235. total_percentage
  236. end
  237. def check_typography_compliance
  238. return unless @visual_data[:typography].present?
  239. detected_fonts = @visual_data[:typography][:fonts] || []
  240. brand_fonts = brand.font_families
  241. non_compliant_fonts = detected_fonts - brand_fonts.values.flatten
  242. if non_compliant_fonts.any?
  243. add_violation(
  244. type: "typography_violation",
  245. severity: "medium",
  246. message: "Non-brand fonts detected",
  247. details: {
  248. non_compliant_fonts: non_compliant_fonts,
  249. brand_fonts: brand_fonts
  250. }
  251. )
  252. end
  253. # Check font hierarchy
  254. check_font_hierarchy(detected_fonts)
  255. # Check text legibility
  256. check_text_legibility
  257. end
  258. def check_font_hierarchy(detected_fonts)
  259. if detected_fonts.length > 3
  260. add_violation(
  261. type: "font_hierarchy",
  262. severity: "low",
  263. message: "Too many font variations",
  264. details: {
  265. font_count: detected_fonts.length,
  266. recommendation: "Limit to 2-3 font variations for better hierarchy"
  267. }
  268. )
  269. end
  270. end
  271. def check_text_legibility
  272. return unless @visual_data[:typography][:legibility_score]
  273. score = @visual_data[:typography][:legibility_score]
  274. if score < 0.6
  275. add_violation(
  276. type: "text_legibility",
  277. severity: "high",
  278. message: "Text legibility issues detected",
  279. details: {
  280. legibility_score: score,
  281. issues: @visual_data[:typography][:legibility_issues] || []
  282. }
  283. )
  284. elsif score < 0.8
  285. add_suggestion(
  286. type: "legibility_improvement",
  287. message: "Text legibility could be improved",
  288. details: {
  289. current_score: score,
  290. suggestions: suggest_legibility_improvements
  291. }
  292. )
  293. end
  294. end
  295. def check_logo_compliance
  296. return unless @visual_data[:logo].present?
  297. logo_data = @visual_data[:logo]
  298. # Check logo size
  299. check_logo_size(logo_data)
  300. # Check logo clear space
  301. check_logo_clear_space(logo_data)
  302. # Check logo placement
  303. check_logo_placement(logo_data)
  304. # Check logo modifications
  305. check_logo_integrity(logo_data)
  306. end
  307. def check_logo_size(logo_data)
  308. min_size = brand.brand_guidelines
  309. .by_category("logo")
  310. .find { |g| g.metadata&.dig("min_size") }
  311. &.metadata&.dig("min_size") || 100
  312. if logo_data[:size] && logo_data[:size] < min_size
  313. add_violation(
  314. type: "logo_size",
  315. severity: "high",
  316. message: "Logo is below minimum size requirements",
  317. details: {
  318. current_size: logo_data[:size],
  319. minimum_size: min_size
  320. }
  321. )
  322. end
  323. end
  324. def check_logo_clear_space(logo_data)
  325. return unless logo_data[:clear_space_ratio]
  326. min_clear_space = 0.5 # Half the logo height/width
  327. if logo_data[:clear_space_ratio] < min_clear_space
  328. add_violation(
  329. type: "logo_clear_space",
  330. severity: "medium",
  331. message: "Insufficient clear space around logo",
  332. details: {
  333. current_ratio: logo_data[:clear_space_ratio],
  334. required_ratio: min_clear_space
  335. }
  336. )
  337. end
  338. end
  339. def check_logo_placement(logo_data)
  340. approved_placements = brand.brand_guidelines
  341. .by_category("logo")
  342. .find { |g| g.metadata&.dig("approved_placements") }
  343. &.metadata&.dig("approved_placements") ||
  344. ["top-left", "top-center", "center"]
  345. if logo_data[:placement] && !approved_placements.include?(logo_data[:placement])
  346. add_violation(
  347. type: "logo_placement",
  348. severity: "medium",
  349. message: "Logo placed in non-approved position",
  350. details: {
  351. current_placement: logo_data[:placement],
  352. approved_placements: approved_placements
  353. }
  354. )
  355. end
  356. end
  357. def check_logo_integrity(logo_data)
  358. if logo_data[:modified]
  359. modifications = logo_data[:modifications] || []
  360. add_violation(
  361. type: "logo_modification",
  362. severity: "critical",
  363. message: "Logo has been modified",
  364. details: {
  365. modifications: modifications,
  366. rule: "Logo must not be altered in any way"
  367. }
  368. )
  369. end
  370. end
  371. def check_composition_compliance
  372. return unless @visual_data[:composition]
  373. composition = @visual_data[:composition]
  374. # Check balance
  375. if composition[:balance_score] && composition[:balance_score] < 0.6
  376. add_suggestion(
  377. type: "composition_balance",
  378. message: "Visual composition could be better balanced",
  379. details: {
  380. balance_score: composition[:balance_score],
  381. suggestions: ["Redistribute visual weight", "Align elements to grid"]
  382. }
  383. )
  384. end
  385. # Check whitespace
  386. check_whitespace_usage(composition)
  387. # Check visual hierarchy
  388. check_visual_hierarchy(composition)
  389. end
  390. def check_whitespace_usage(composition)
  391. whitespace_ratio = composition[:whitespace_ratio] || 0
  392. if whitespace_ratio < 0.2
  393. add_violation(
  394. type: "whitespace_insufficient",
  395. severity: "medium",
  396. message: "Insufficient whitespace",
  397. details: {
  398. current_ratio: whitespace_ratio,
  399. recommendation: "Increase whitespace for better readability"
  400. }
  401. )
  402. elsif whitespace_ratio > 0.7
  403. add_suggestion(
  404. type: "whitespace_excessive",
  405. message: "Consider using space more efficiently",
  406. details: {
  407. current_ratio: whitespace_ratio
  408. }
  409. )
  410. end
  411. end
  412. def check_visual_hierarchy(composition)
  413. hierarchy_score = composition[:hierarchy_score] || 0
  414. if hierarchy_score < 0.5
  415. add_violation(
  416. type: "visual_hierarchy",
  417. severity: "medium",
  418. message: "Weak visual hierarchy",
  419. details: {
  420. hierarchy_score: hierarchy_score,
  421. issues: composition[:hierarchy_issues] || [],
  422. suggestions: [
  423. "Use size contrast for importance",
  424. "Apply consistent spacing",
  425. "Group related elements"
  426. ]
  427. }
  428. )
  429. end
  430. end
  431. def check_quality_standards
  432. return unless @visual_data[:quality]
  433. quality = @visual_data[:quality]
  434. # Check resolution
  435. if quality[:resolution] && quality[:resolution] < 72
  436. add_violation(
  437. type: "low_resolution",
  438. severity: "high",
  439. message: "Image resolution too low",
  440. details: {
  441. current_dpi: quality[:resolution],
  442. minimum_dpi: 72,
  443. recommendation: "Use images with at least 72 DPI for web, 300 DPI for print"
  444. }
  445. )
  446. end
  447. # Check compression artifacts
  448. if quality[:compression_score] && quality[:compression_score] < 0.7
  449. add_suggestion(
  450. type: "compression_quality",
  451. message: "Image shows compression artifacts",
  452. details: {
  453. quality_score: quality[:compression_score],
  454. recommendation: "Use higher quality compression settings"
  455. }
  456. )
  457. end
  458. # Check file size
  459. check_file_size_optimization(quality)
  460. end
  461. def check_file_size_optimization(quality)
  462. return unless quality[:file_size] && quality[:dimensions]
  463. # Calculate bytes per pixel
  464. total_pixels = quality[:dimensions][:width] * quality[:dimensions][:height]
  465. bytes_per_pixel = quality[:file_size].to_f / total_pixels
  466. # Rough guidelines for web images
  467. if bytes_per_pixel > 1.5
  468. add_suggestion(
  469. type: "file_size_optimization",
  470. message: "Image file size could be optimized",
  471. details: {
  472. current_size: quality[:file_size],
  473. bytes_per_pixel: bytes_per_pixel.round(2),
  474. recommendation: "Consider optimizing without quality loss"
  475. }
  476. )
  477. end
  478. end
  479. def check_visual_accessibility
  480. # Check color contrast
  481. check_color_contrast
  482. # Check for alt text (if applicable)
  483. check_alt_text
  484. # Check for motion/animation issues
  485. check_motion_accessibility
  486. end
  487. def check_color_contrast
  488. return unless @visual_data[:accessibility]
  489. contrast_issues = @visual_data[:accessibility][:contrast_issues] || []
  490. if contrast_issues.any?
  491. add_violation(
  492. type: "color_contrast",
  493. severity: "high",
  494. message: "Color contrast accessibility issues",
  495. details: {
  496. issues: contrast_issues,
  497. wcag_level: "AA",
  498. recommendation: "Ensure 4.5:1 contrast for normal text, 3:1 for large text"
  499. }
  500. )
  501. end
  502. end
  503. def check_alt_text
  504. return unless options[:requires_alt_text]
  505. if @visual_data[:alt_text].blank?
  506. add_violation(
  507. type: "missing_alt_text",
  508. severity: "high",
  509. message: "Missing alternative text for accessibility",
  510. details: {
  511. recommendation: "Add descriptive alt text for screen readers"
  512. }
  513. )
  514. elsif @visual_data[:alt_text].length < 10
  515. add_suggestion(
  516. type: "improve_alt_text",
  517. message: "Alt text could be more descriptive",
  518. details: {
  519. current_length: @visual_data[:alt_text].length,
  520. recommendation: "Provide meaningful description of the visual content"
  521. }
  522. )
  523. end
  524. end
  525. def check_motion_accessibility
  526. return unless @visual_data[:has_animation]
  527. animation_data = @visual_data[:animation] || {}
  528. if animation_data[:autoplay] && !animation_data[:has_pause_control]
  529. add_violation(
  530. type: "motion_control",
  531. severity: "medium",
  532. message: "Auto-playing animation without pause control",
  533. details: {
  534. recommendation: "Provide user controls for animations",
  535. wcag_guideline: "2.2.2 Pause, Stop, Hide"
  536. }
  537. )
  538. end
  539. if animation_data[:flashing_detected]
  540. add_violation(
  541. type: "flashing_content",
  542. severity: "critical",
  543. message: "Flashing content detected",
  544. details: {
  545. recommendation: "Remove flashing to prevent seizures",
  546. wcag_guideline: "2.3.1 Three Flashes or Below Threshold"
  547. }
  548. )
  549. end
  550. end
  551. def build_visual_analysis_prompt(image_data)
  552. <<~PROMPT
  553. Analyze this image for brand compliance based on these guidelines:
  554. Brand Colors:
  555. Primary: #{brand.primary_colors.to_json}
  556. Secondary: #{brand.secondary_colors.to_json}
  557. Brand Fonts:
  558. #{brand.font_families.to_json}
  559. Visual Guidelines:
  560. #{extract_visual_guidelines.to_json}
  561. Please analyze:
  562. 1. Color usage and compliance
  563. 2. Typography (if text is present)
  564. 3. Logo usage and placement
  565. 4. Overall composition and balance
  566. 5. Brand consistency
  567. Return analysis in JSON format with detailed findings.
  568. PROMPT
  569. end
  570. def extract_visual_guidelines
  571. guidelines = {}
  572. %w[logo color typography composition].each do |category|
  573. category_guidelines = brand.brand_guidelines.by_category(category)
  574. guidelines[category] = category_guidelines.map do |g|
  575. {
  576. rule: g.rule_content,
  577. type: g.rule_type,
  578. mandatory: g.mandatory?
  579. }
  580. end
  581. end
  582. guidelines
  583. end
  584. def suggest_legibility_improvements
  585. [
  586. "Increase font size for body text",
  587. "Improve contrast between text and background",
  588. "Use simpler fonts for better readability",
  589. "Increase line spacing",
  590. "Avoid thin font weights for small text"
  591. ]
  592. end
  593. def parse_json_response(response)
  594. return nil if response.nil?
  595. begin
  596. JSON.parse(response, symbolize_names: true)
  597. rescue JSON::ParserError
  598. Rails.logger.error "Failed to parse visual analysis response"
  599. nil
  600. end
  601. end
  602. end
  603. end
  604. end

app/services/branding/compliance_service.rb

0.0% lines covered

296 relevant lines. 0 lines covered and 296 lines missed.
    
  1. module Branding
  2. class ComplianceService
  3. attr_reader :brand, :content, :content_type
  4. COMPLIANCE_THRESHOLDS = {
  5. high: 0.9,
  6. medium: 0.7,
  7. low: 0.5
  8. }.freeze
  9. def initialize(brand, content, content_type = "general")
  10. @brand = brand
  11. @content = content
  12. @content_type = content_type
  13. @violations = []
  14. @suggestions = []
  15. @score = 0.0
  16. end
  17. def check_compliance
  18. return build_response(false, "No content provided") if content.blank?
  19. return build_response(false, "No brand specified") if brand.blank?
  20. # Run all compliance checks
  21. check_banned_words
  22. check_tone_compliance
  23. check_messaging_alignment
  24. check_style_guidelines
  25. check_required_elements
  26. check_visual_compliance if visual_content?
  27. # Calculate overall compliance score
  28. calculate_compliance_score
  29. build_response(true)
  30. end
  31. def validate_and_suggest
  32. result = check_compliance
  33. if result[:compliant]
  34. result[:suggestions] = generate_improvements
  35. else
  36. result[:corrections] = generate_corrections
  37. end
  38. result
  39. end
  40. private
  41. def check_banned_words
  42. messaging_framework = brand.messaging_framework
  43. return unless messaging_framework
  44. banned_words = messaging_framework.get_banned_words_in_text(content)
  45. if banned_words.any?
  46. add_violation(
  47. type: "banned_words",
  48. severity: "high",
  49. message: "Content contains banned words: #{banned_words.join(', ')}",
  50. details: banned_words
  51. )
  52. end
  53. end
  54. def check_tone_compliance
  55. analysis = brand.latest_analysis
  56. return unless analysis
  57. expected_tone = analysis.voice_attributes.dig("tone", "primary")
  58. detected_tone = analyze_content_tone
  59. if tone_mismatch?(expected_tone, detected_tone)
  60. add_violation(
  61. type: "tone_mismatch",
  62. severity: "medium",
  63. message: "Content tone (#{detected_tone}) doesn't match brand tone (#{expected_tone})",
  64. details: {
  65. expected: expected_tone,
  66. detected: detected_tone
  67. }
  68. )
  69. end
  70. end
  71. def check_messaging_alignment
  72. messaging_framework = brand.messaging_framework
  73. return unless messaging_framework
  74. key_messages = messaging_framework.key_messages.values.flatten
  75. value_props = messaging_framework.value_propositions["main"] || []
  76. alignment_score = calculate_message_alignment(key_messages + value_props)
  77. if alignment_score < 0.3
  78. add_violation(
  79. type: "messaging_misalignment",
  80. severity: "medium",
  81. message: "Content doesn't align well with brand key messages",
  82. details: {
  83. alignment_score: alignment_score,
  84. missing_themes: identify_missing_themes(key_messages)
  85. }
  86. )
  87. elsif alignment_score < 0.6
  88. add_suggestion(
  89. type: "messaging_improvement",
  90. message: "Consider incorporating more brand key messages",
  91. details: {
  92. current_alignment: alignment_score,
  93. suggested_themes: identify_missing_themes(key_messages).first(3)
  94. }
  95. )
  96. end
  97. end
  98. def check_style_guidelines
  99. guidelines = brand.brand_guidelines.active.by_category("style")
  100. guidelines.each do |guideline|
  101. if guideline.mandatory? && !content_follows_guideline?(guideline)
  102. add_violation(
  103. type: "style_violation",
  104. severity: guideline.priority >= 8 ? "high" : "medium",
  105. message: "Violates style guideline: #{guideline.rule_content}",
  106. details: {
  107. rule_type: guideline.rule_type,
  108. guideline_id: guideline.id
  109. }
  110. )
  111. end
  112. end
  113. end
  114. def check_required_elements
  115. required_guidelines = brand.brand_guidelines.mandatory_rules
  116. required_guidelines.each do |guideline|
  117. next if content_includes_required_element?(guideline)
  118. add_violation(
  119. type: "missing_required_element",
  120. severity: "high",
  121. message: "Missing required element: #{guideline.rule_content}",
  122. details: {
  123. guideline_id: guideline.id,
  124. category: guideline.category
  125. }
  126. )
  127. end
  128. end
  129. def check_visual_compliance
  130. # Placeholder for visual content compliance checks
  131. # Would check colors, fonts, logo usage, etc.
  132. end
  133. def analyze_content_tone
  134. # Simplified tone detection - in production would use NLP
  135. formal_indicators = %w[therefore however furthermore consequently]
  136. casual_indicators = %w[hey gonna wanna cool awesome]
  137. content_lower = content.downcase
  138. formal_count = formal_indicators.count { |word| content_lower.include?(word) }
  139. casual_count = casual_indicators.count { |word| content_lower.include?(word) }
  140. if formal_count > casual_count * 2
  141. "formal"
  142. elsif casual_count > formal_count * 2
  143. "casual"
  144. else
  145. "neutral"
  146. end
  147. end
  148. def tone_mismatch?(expected, detected)
  149. tone_compatibility = {
  150. "formal" => ["formal", "professional"],
  151. "professional" => ["formal", "professional", "neutral"],
  152. "friendly" => ["friendly", "casual", "neutral"],
  153. "casual" => ["casual", "friendly"]
  154. }
  155. compatible_tones = tone_compatibility[expected] || [expected]
  156. !compatible_tones.include?(detected)
  157. end
  158. def calculate_message_alignment(key_messages)
  159. return 0.0 if key_messages.empty?
  160. content_lower = content.downcase
  161. matched_messages = key_messages.count do |message|
  162. message_words = message.downcase.split(/\W+/)
  163. message_words.any? { |word| content_lower.include?(word) }
  164. end
  165. matched_messages.to_f / key_messages.size
  166. end
  167. def identify_missing_themes(key_messages)
  168. content_lower = content.downcase
  169. key_messages.reject do |message|
  170. message_words = message.downcase.split(/\W+/)
  171. message_words.any? { |word| content_lower.include?(word) }
  172. end
  173. end
  174. def content_follows_guideline?(guideline)
  175. case guideline.rule_type
  176. when "do", "must"
  177. # Check if content follows positive guideline
  178. guideline_keywords = extract_keywords(guideline.rule_content)
  179. guideline_keywords.any? { |keyword| content.downcase.include?(keyword.downcase) }
  180. when "dont", "avoid"
  181. # Check if content avoids negative guideline
  182. guideline_keywords = extract_keywords(guideline.rule_content)
  183. guideline_keywords.none? { |keyword| content.downcase.include?(keyword.downcase) }
  184. else
  185. true
  186. end
  187. end
  188. def content_includes_required_element?(guideline)
  189. return true unless guideline.rule_type == "must"
  190. # Check if required element is present
  191. required_keywords = extract_keywords(guideline.rule_content)
  192. required_keywords.any? { |keyword| content.downcase.include?(keyword.downcase) }
  193. end
  194. def extract_keywords(text)
  195. # Extract meaningful keywords from guideline text
  196. stop_words = %w[the a an and or but in on at to for of with as by]
  197. text.downcase
  198. .split(/\W+/)
  199. .reject { |word| stop_words.include?(word) || word.length < 3 }
  200. end
  201. def calculate_compliance_score
  202. return 1.0 if @violations.empty?
  203. # Weight violations by severity
  204. severity_weights = { high: 1.0, medium: 0.5, low: 0.25 }
  205. total_weight = @violations.sum do |violation|
  206. severity_weights[violation[:severity].to_sym] || 0.5
  207. end
  208. # Calculate score (0-1 scale)
  209. max_possible_violations = 10.0 # Assumed maximum
  210. @score = [1.0 - (total_weight / max_possible_violations), 0].max
  211. end
  212. def generate_improvements
  213. improvements = []
  214. # Suggest incorporating more key messages if alignment is moderate
  215. if @score > 0.7 && @score < 0.9
  216. improvements << {
  217. type: "enhance_messaging",
  218. suggestion: "Consider adding more brand-specific value propositions",
  219. priority: "low"
  220. }
  221. end
  222. # Suggest tone adjustments
  223. if @suggestions.any? { |s| s[:type] == "tone_adjustment" }
  224. improvements << {
  225. type: "refine_tone",
  226. suggestion: "Fine-tune the tone to better match brand voice",
  227. priority: "medium"
  228. }
  229. end
  230. improvements + @suggestions
  231. end
  232. def generate_corrections
  233. @violations.map do |violation|
  234. {
  235. type: violation[:type],
  236. correction: suggest_correction_for(violation),
  237. priority: violation[:severity],
  238. details: violation[:details]
  239. }
  240. end
  241. end
  242. def suggest_correction_for(violation)
  243. case violation[:type]
  244. when "banned_words"
  245. "Replace the following banned words: #{violation[:details].join(', ')}"
  246. when "tone_mismatch"
  247. "Adjust tone from #{violation[:details][:detected]} to #{violation[:details][:expected]}"
  248. when "missing_required_element"
  249. "Add required element: #{violation[:message]}"
  250. when "style_violation"
  251. "Follow style guideline: #{violation[:message]}"
  252. else
  253. "Address issue: #{violation[:message]}"
  254. end
  255. end
  256. def visual_content?
  257. %w[image video infographic].include?(content_type)
  258. end
  259. def add_violation(type:, severity:, message:, details: {})
  260. @violations << {
  261. type: type,
  262. severity: severity,
  263. message: message,
  264. details: details,
  265. timestamp: Time.current
  266. }
  267. end
  268. def add_suggestion(type:, message:, details: {})
  269. @suggestions << {
  270. type: type,
  271. message: message,
  272. details: details,
  273. timestamp: Time.current
  274. }
  275. end
  276. def build_response(success, error_message = nil)
  277. if success
  278. {
  279. compliant: @violations.empty?,
  280. score: @score,
  281. violations: @violations,
  282. suggestions: @suggestions,
  283. summary: compliance_summary
  284. }
  285. else
  286. {
  287. compliant: false,
  288. score: 0,
  289. error: error_message,
  290. violations: [],
  291. suggestions: []
  292. }
  293. end
  294. end
  295. def compliance_summary
  296. if @violations.empty?
  297. "Content is fully compliant with brand guidelines."
  298. elsif @score >= COMPLIANCE_THRESHOLDS[:high]
  299. "Content is highly compliant with minor adjustments needed."
  300. elsif @score >= COMPLIANCE_THRESHOLDS[:medium]
  301. "Content is moderately compliant. Several improvements recommended."
  302. elsif @score >= COMPLIANCE_THRESHOLDS[:low]
  303. "Content has compliance issues that should be addressed."
  304. else
  305. "Content has significant compliance violations requiring major revisions."
  306. end
  307. end
  308. end
  309. end

app/services/branding/compliance_service_v2.rb

0.0% lines covered

379 relevant lines. 0 lines covered and 379 lines missed.
    
  1. module Branding
  2. class ComplianceServiceV2
  3. include ActiveSupport::Configurable
  4. config_accessor :cache_store, default: Rails.cache
  5. config_accessor :broadcast_violations, default: true
  6. config_accessor :async_processing, default: true
  7. config_accessor :max_processing_time, default: 30.seconds
  8. attr_reader :brand, :content, :content_type, :options
  9. COMPLIANCE_LEVELS = {
  10. strict: { threshold: 0.95, tolerance: :none },
  11. standard: { threshold: 0.85, tolerance: :low },
  12. flexible: { threshold: 0.70, tolerance: :medium },
  13. advisory: { threshold: 0.50, tolerance: :high }
  14. }.freeze
  15. def initialize(brand, content, content_type = "general", options = {})
  16. @brand = brand
  17. @content = content
  18. @content_type = content_type
  19. @options = default_options.merge(options)
  20. @validators = []
  21. @results = {}
  22. setup_validators
  23. end
  24. def check_compliance
  25. start_time = Time.current
  26. # Run validations based on configuration
  27. if options[:async] && content_large?
  28. check_compliance_async
  29. else
  30. check_compliance_sync
  31. end
  32. # Compile results
  33. compile_results
  34. # Generate suggestions if requested
  35. if options[:generate_suggestions]
  36. @results[:suggestions] = generate_intelligent_suggestions
  37. end
  38. # Add metadata
  39. @results[:metadata] = {
  40. processing_time: Time.current - start_time,
  41. validators_used: @validators.map(&:class).map(&:name),
  42. compliance_level: options[:compliance_level],
  43. cached_results_used: @results[:cache_hits] || 0
  44. }
  45. @results
  46. rescue StandardError => e
  47. handle_error(e)
  48. end
  49. def validate_and_fix
  50. compliance_results = check_compliance
  51. return compliance_results if compliance_results[:compliant]
  52. # Attempt to auto-fix violations
  53. fix_results = auto_fix_violations(compliance_results[:violations])
  54. # Re-validate fixed content if changes were made
  55. if fix_results[:content_changed]
  56. @content = fix_results[:fixed_content]
  57. revalidation_results = check_compliance
  58. {
  59. original_results: compliance_results,
  60. fixes_applied: fix_results[:fixes],
  61. final_results: revalidation_results,
  62. fixed_content: fix_results[:fixed_content]
  63. }
  64. else
  65. compliance_results.merge(fixes_available: fix_results[:fixes])
  66. end
  67. end
  68. def check_specific_aspects(aspects)
  69. results = {}
  70. aspects.each do |aspect|
  71. validator = validator_for_aspect(aspect)
  72. next unless validator
  73. result = run_validator(validator)
  74. results[aspect] = result
  75. end
  76. compile_aspect_results(results)
  77. end
  78. def preview_fixes(violations = nil)
  79. violations ||= @results[:violations] || []
  80. suggestion_engine = Compliance::SuggestionEngine.new(brand, violations, @results)
  81. fixes = {}
  82. violations.each do |violation|
  83. fixes[violation[:id]] = suggestion_engine.generate_fix(violation, content)
  84. end
  85. fixes
  86. end
  87. private
  88. def default_options
  89. {
  90. compliance_level: :standard,
  91. async: config.async_processing,
  92. generate_suggestions: true,
  93. real_time_updates: config.broadcast_violations,
  94. cache_results: true,
  95. include_visual: content_type.include?("visual") || content_type.include?("image"),
  96. nlp_analysis_depth: :full,
  97. timeout: config.max_processing_time
  98. }
  99. end
  100. def setup_validators
  101. # Always include rule engine
  102. @validators << Compliance::RuleEngine.new(brand)
  103. # NLP analyzer for text content
  104. if has_text_content?
  105. @validators << Compliance::NlpAnalyzer.new(brand, content, options)
  106. end
  107. # Visual validator for visual content
  108. if options[:include_visual] && options[:visual_data]
  109. @validators << Compliance::VisualValidator.new(brand, content, options)
  110. end
  111. # Add custom validators if provided
  112. if options[:custom_validators]
  113. @validators.concat(options[:custom_validators])
  114. end
  115. end
  116. def check_compliance_sync
  117. @validators.each do |validator|
  118. result = run_validator(validator)
  119. merge_validator_results(result, validator)
  120. end
  121. end
  122. def check_compliance_async
  123. futures = @validators.map do |validator|
  124. Concurrent::Future.execute do
  125. run_validator(validator)
  126. end
  127. end
  128. # Wait for all validators with timeout
  129. futures.each_with_index do |future, index|
  130. if future.wait(options[:timeout])
  131. merge_validator_results(future.value, @validators[index])
  132. else
  133. @results[:errors] ||= []
  134. @results[:errors] << {
  135. validator: @validators[index].class.name,
  136. error: "Timeout exceeded"
  137. }
  138. end
  139. end
  140. end
  141. def run_validator(validator)
  142. cache_key = validator_cache_key(validator)
  143. if options[:cache_results] && cache_store
  144. cached = cache_store.fetch(cache_key, expires_in: 5.minutes) do
  145. run_validator_safely(validator)
  146. end
  147. @results[:cache_hits] ||= 0
  148. @results[:cache_hits] += 1 if cached[:cached]
  149. cached
  150. else
  151. run_validator_safely(validator)
  152. end
  153. end
  154. def run_validator_safely(validator)
  155. if validator.is_a?(Compliance::RuleEngine)
  156. # Rule engine has different interface
  157. context = {
  158. content_type: content_type,
  159. channel: options[:channel],
  160. audience: options[:audience]
  161. }
  162. validator.evaluate(content, context)
  163. else
  164. validator.validate
  165. end
  166. rescue StandardError => e
  167. {
  168. error: e.message,
  169. validator: validator.class.name,
  170. violations: [],
  171. suggestions: []
  172. }
  173. end
  174. def merge_validator_results(result, validator)
  175. return if result[:error]
  176. # Merge violations
  177. if result[:violations]
  178. @results[:violations] ||= []
  179. @results[:violations].concat(normalize_violations(result[:violations], validator))
  180. elsif result[:failed]
  181. # Handle RuleEngine format
  182. @results[:violations] ||= []
  183. @results[:violations].concat(convert_rule_failures(result[:failed]))
  184. end
  185. # Merge suggestions
  186. if result[:suggestions]
  187. @results[:suggestions] ||= []
  188. @results[:suggestions].concat(result[:suggestions])
  189. elsif result[:warnings]
  190. # Handle RuleEngine warnings as suggestions
  191. @results[:suggestions] ||= []
  192. @results[:suggestions].concat(convert_rule_warnings(result[:warnings]))
  193. end
  194. # Store analysis results
  195. if result[:analysis]
  196. @results[:analysis] ||= {}
  197. @results[:analysis][validator.class.name.demodulize.underscore] = result[:analysis]
  198. end
  199. # Track scores
  200. if result[:score]
  201. @results[:scores] ||= {}
  202. @results[:scores][validator.class.name.demodulize.underscore] = result[:score]
  203. end
  204. end
  205. def normalize_violations(violations, validator)
  206. violations.map.with_index do |violation, index|
  207. violation.merge(
  208. id: "#{validator.class.name.demodulize.underscore}_#{index}",
  209. validator_type: validator.class.name.demodulize.underscore
  210. )
  211. end
  212. end
  213. def convert_rule_failures(failures)
  214. failures.map do |failure|
  215. {
  216. id: failure[:rule_id],
  217. type: "rule_violation",
  218. severity: failure[:severity],
  219. message: failure[:message],
  220. details: failure[:details],
  221. validator_type: "rule_engine"
  222. }
  223. end
  224. end
  225. def convert_rule_warnings(warnings)
  226. warnings.map do |warning|
  227. {
  228. type: "rule_warning",
  229. message: warning[:message],
  230. details: warning[:details],
  231. priority: "low"
  232. }
  233. end
  234. end
  235. def compile_results
  236. violations = @results[:violations] || []
  237. suggestions = @results[:suggestions] || []
  238. # Calculate overall compliance
  239. compliance_level = COMPLIANCE_LEVELS[options[:compliance_level]]
  240. score = calculate_overall_score
  241. @results[:compliant] = violations.empty? ||
  242. (score >= compliance_level[:threshold] &&
  243. allows_violations?(violations, compliance_level))
  244. @results[:score] = score
  245. @results[:summary] = generate_summary(score, violations, suggestions)
  246. @results[:violations] = prioritize_violations(violations)
  247. @results[:suggestions] = deduplicate_suggestions(suggestions)
  248. # Broadcast if enabled
  249. broadcast_results if options[:real_time_updates]
  250. @results
  251. end
  252. def calculate_overall_score
  253. scores = @results[:scores] || {}
  254. return 1.0 if scores.empty?
  255. # Weight scores based on validator importance
  256. weights = {
  257. "rule_engine" => 0.4,
  258. "nlp_analyzer" => 0.35,
  259. "visual_validator" => 0.25
  260. }
  261. weighted_sum = 0.0
  262. total_weight = 0.0
  263. scores.each do |validator, score|
  264. weight = weights[validator] || 0.2
  265. weighted_sum += score * weight
  266. total_weight += weight
  267. end
  268. total_weight > 0 ? (weighted_sum / total_weight).round(3) : 0.0
  269. end
  270. def allows_violations?(violations, compliance_level)
  271. case compliance_level[:tolerance]
  272. when :none
  273. false
  274. when :low
  275. violations.none? { |v| %w[critical high].include?(v[:severity]) }
  276. when :medium
  277. violations.none? { |v| v[:severity] == "critical" }
  278. when :high
  279. true
  280. end
  281. end
  282. def generate_summary(score, violations, suggestions)
  283. severity_counts = violations.group_by { |v| v[:severity] }.transform_values(&:count)
  284. if violations.empty?
  285. "Content is fully compliant with brand guidelines (score: #{(score * 100).round}%)."
  286. elsif score >= 0.9
  287. "Content is highly compliant with minor issues (score: #{(score * 100).round}%)."
  288. elsif score >= 0.7
  289. "Content is moderately compliant. #{severity_counts.map { |s, c| "#{c} #{s}" }.join(', ')} violations found."
  290. elsif score >= 0.5
  291. "Content has compliance issues that should be addressed. #{violations.count} violations found."
  292. else
  293. "Content has significant compliance violations requiring major revisions."
  294. end
  295. end
  296. def prioritize_violations(violations)
  297. severity_order = { "critical" => 0, "high" => 1, "medium" => 2, "low" => 3 }
  298. violations.sort_by do |violation|
  299. [
  300. severity_order[violation[:severity]] || 4,
  301. violation[:type],
  302. violation[:message]
  303. ]
  304. end
  305. end
  306. def deduplicate_suggestions(suggestions)
  307. suggestions.uniq { |s| [s[:type], s[:message]] }
  308. .sort_by { |s| s[:priority] == "high" ? 0 : 1 }
  309. end
  310. def generate_intelligent_suggestions
  311. all_violations = @results[:violations] || []
  312. analysis_data = @results[:analysis] || {}
  313. suggestion_engine = Compliance::SuggestionEngine.new(brand, all_violations, analysis_data)
  314. suggestion_engine.generate_suggestions
  315. end
  316. def auto_fix_violations(violations)
  317. return { content_changed: false, fixes: [] } if violations.empty?
  318. suggestion_engine = Compliance::SuggestionEngine.new(brand, violations, @results[:analysis])
  319. fixed_content = content.dup
  320. fixes_applied = []
  321. # Apply fixes in order of severity
  322. violations.each do |violation|
  323. fix = suggestion_engine.generate_fix(violation, fixed_content)
  324. if fix[:confidence] > 0.7
  325. fixed_content = fix[:fixed_content]
  326. fixes_applied << {
  327. violation_id: violation[:id],
  328. fix_applied: fix[:changes_made],
  329. confidence: fix[:confidence]
  330. }
  331. end
  332. end
  333. {
  334. content_changed: fixes_applied.any?,
  335. fixed_content: fixed_content,
  336. fixes: fixes_applied
  337. }
  338. end
  339. def broadcast_results
  340. return unless config.broadcast_violations
  341. ActionCable.server.broadcast(
  342. "brand_compliance_#{brand.id}",
  343. {
  344. event: "compliance_check_complete",
  345. compliant: @results[:compliant],
  346. score: @results[:score],
  347. violations_count: (@results[:violations] || []).count,
  348. suggestions_count: (@results[:suggestions] || []).count
  349. }
  350. )
  351. end
  352. def validator_cache_key(validator)
  353. [
  354. "brand_compliance",
  355. brand.id,
  356. validator.class.name.underscore,
  357. Digest::MD5.hexdigest(content.to_s),
  358. content_type
  359. ].join(":")
  360. end
  361. def content_large?
  362. content.length > 10_000
  363. end
  364. def has_text_content?
  365. content.is_a?(String) && content.present?
  366. end
  367. def validator_for_aspect(aspect)
  368. case aspect
  369. when :tone, :readability, :sentiment, :brand_voice
  370. Compliance::NlpAnalyzer.new(brand, content, options)
  371. when :colors, :typography, :logo, :composition
  372. Compliance::VisualValidator.new(brand, content, options)
  373. when :rules, :guidelines
  374. Compliance::RuleEngine.new(brand)
  375. else
  376. nil
  377. end
  378. end
  379. def compile_aspect_results(aspect_results)
  380. {
  381. aspects_checked: aspect_results.keys,
  382. compliant: aspect_results.values.none? { |r| r[:violations]&.any? },
  383. results: aspect_results,
  384. summary: "Checked #{aspect_results.keys.join(', ')} aspects"
  385. }
  386. end
  387. def handle_error(error)
  388. Rails.logger.error "Compliance check error: #{error.message}"
  389. Rails.logger.error error.backtrace.join("\n")
  390. {
  391. compliant: false,
  392. error: error.message,
  393. error_type: error.class.name,
  394. violations: [],
  395. suggestions: [],
  396. score: 0.0,
  397. summary: "Compliance check failed due to an error"
  398. }
  399. end
  400. end
  401. end

app/services/branding/compliance_usage_example.rb

0.0% lines covered

188 relevant lines. 0 lines covered and 188 lines missed.
    
  1. # Example usage of the enhanced Brand Compliance Validation Service
  2. module Branding
  3. class ComplianceUsageExample
  4. def self.demonstrate
  5. # 1. Basic compliance check
  6. brand = Brand.first
  7. content = "Check out our amazing new product! It's the best solution for your needs."
  8. service = ComplianceServiceV2.new(brand, content, "marketing_copy")
  9. results = service.check_compliance
  10. puts "=== Basic Compliance Check ==="
  11. puts "Compliant: #{results[:compliant]}"
  12. puts "Score: #{results[:score]}"
  13. puts "Summary: #{results[:summary]}"
  14. puts "Violations: #{results[:violations].count}"
  15. puts "Suggestions: #{results[:suggestions].count}"
  16. puts
  17. # 2. Check specific aspects
  18. puts "=== Specific Aspect Validation ==="
  19. aspect_results = service.check_specific_aspects([:tone, :readability])
  20. aspect_results.each do |aspect, result|
  21. puts "#{aspect}: #{result[:violations].count} violations"
  22. end
  23. puts
  24. # 3. Auto-fix violations
  25. puts "=== Auto-Fix Violations ==="
  26. fix_results = service.validate_and_fix
  27. if fix_results[:fixes_applied]
  28. puts "Original compliant: #{fix_results[:original_results][:compliant]}"
  29. puts "Fixes applied: #{fix_results[:fixes_applied].count}"
  30. puts "Final compliant: #{fix_results[:final_results][:compliant]}"
  31. puts "Fixed content preview: #{fix_results[:fixed_content][0..100]}..."
  32. end
  33. puts
  34. # 4. Visual content compliance
  35. puts "=== Visual Content Compliance ==="
  36. visual_data = {
  37. colors: {
  38. primary: ["#1E40AF", "#3B82F6"],
  39. secondary: ["#10B981", "#34D399"]
  40. },
  41. typography: {
  42. fonts: ["Inter", "Roboto"],
  43. legibility_score: 0.85
  44. },
  45. logo: {
  46. size: 150,
  47. placement: "top-left",
  48. clear_space_ratio: 0.6
  49. },
  50. quality: {
  51. resolution: 72,
  52. file_size: 250_000,
  53. dimensions: { width: 1200, height: 600 }
  54. }
  55. }
  56. visual_service = ComplianceServiceV2.new(
  57. brand,
  58. "Visual content description",
  59. "image",
  60. { visual_data: visual_data }
  61. )
  62. visual_results = visual_service.check_compliance
  63. puts "Visual compliance score: #{visual_results[:score]}"
  64. puts
  65. # 5. Async processing for large content
  66. puts "=== Async Processing ==="
  67. large_content = "Large content " * 1000 # Simulating large content
  68. job = BrandComplianceJob.perform_later(
  69. brand.id,
  70. large_content,
  71. "article",
  72. {
  73. user_id: brand.user_id,
  74. broadcast_events: true,
  75. store_results: true
  76. }
  77. )
  78. puts "Job queued with ID: #{job.job_id}"
  79. puts
  80. # 6. Using the API endpoint
  81. puts "=== API Usage Example ==="
  82. puts <<~CURL
  83. # Check compliance via API
  84. curl -X POST http://localhost:3000/api/v1/brands/#{brand.id}/compliance/check \\
  85. -H "Content-Type: application/json" \\
  86. -H "Authorization: Bearer YOUR_TOKEN" \\
  87. -d '{
  88. "content": "Your content here",
  89. "content_type": "social_media",
  90. "compliance_level": "strict",
  91. "channel": "twitter",
  92. "audience": "b2b_professionals"
  93. }'
  94. # Validate specific aspect
  95. curl -X POST http://localhost:3000/api/v1/brands/#{brand.id}/compliance/validate_aspect \\
  96. -H "Content-Type: application/json" \\
  97. -H "Authorization: Bearer YOUR_TOKEN" \\
  98. -d '{
  99. "aspect": "tone",
  100. "content": "Your content here"
  101. }'
  102. # Preview fix for violation
  103. curl -X POST http://localhost:3000/api/v1/brands/#{brand.id}/compliance/preview_fix \\
  104. -H "Content-Type: application/json" \\
  105. -H "Authorization: Bearer YOUR_TOKEN" \\
  106. -d '{
  107. "violation": {
  108. "id": "tone_1",
  109. "type": "tone_mismatch",
  110. "severity": "medium",
  111. "details": {
  112. "expected": "professional",
  113. "detected": "casual"
  114. }
  115. },
  116. "content": "Your content here"
  117. }'
  118. CURL
  119. # 7. Real-time updates via ActionCable
  120. puts "\n=== ActionCable Subscription Example ==="
  121. puts <<~JS
  122. // JavaScript client code
  123. const cable = ActionCable.createConsumer('ws://localhost:3000/cable');
  124. const complianceChannel = cable.subscriptions.create(
  125. {
  126. channel: 'BrandComplianceChannel',
  127. brand_id: #{brand.id},
  128. session_id: 'unique-session-id'
  129. },
  130. {
  131. connected() {
  132. console.log('Connected to compliance channel');
  133. // Request compliance check
  134. this.perform('check_compliance', {
  135. content: 'Content to check',
  136. content_type: 'email',
  137. async: true
  138. });
  139. },
  140. received(data) {
  141. switch(data.event) {
  142. case 'validation_started':
  143. console.log('Validation started:', data);
  144. break;
  145. case 'violation_detected':
  146. console.log('Violation found:', data.violation);
  147. break;
  148. case 'validation_complete':
  149. console.log('Validation complete:', data);
  150. break;
  151. }
  152. }
  153. }
  154. );
  155. JS
  156. # 8. Caching and performance
  157. puts "\n=== Cache Management ==="
  158. cache_stats = Branding::Compliance::CacheService.cache_statistics(brand.id)
  159. puts "Cache statistics: #{cache_stats}"
  160. # Warm cache for better performance
  161. Branding::Compliance::CacheWarmerJob.perform_later(brand.id)
  162. puts "Cache warming job queued"
  163. # 9. Compliance history and analytics
  164. puts "\n=== Compliance Analytics ==="
  165. recent_results = brand.compliance_results.recent.limit(10)
  166. puts "Recent checks: #{recent_results.count}"
  167. puts "Average score: #{brand.compliance_results.average_score}"
  168. puts "Compliance rate: #{brand.compliance_results.compliance_rate}%"
  169. puts "Common violations: #{brand.compliance_results.common_violations(3)}"
  170. rescue => e
  171. puts "Error: #{e.message}"
  172. puts e.backtrace.first(5)
  173. end
  174. # Advanced configuration example
  175. def self.configure_compliance_service
  176. # Configure global settings
  177. Branding::ComplianceServiceV2.configure do |config|
  178. config.cache_store = Rails.cache
  179. config.broadcast_violations = true
  180. config.async_processing = true
  181. config.max_processing_time = 60.seconds
  182. end
  183. # Custom validator example
  184. class CustomIndustryValidator < Branding::Compliance::BaseValidator
  185. def validate
  186. # Custom industry-specific validation logic
  187. if brand.industry == "healthcare" && content.match?(/medical claim/i)
  188. add_violation(
  189. type: "unverified_medical_claim",
  190. severity: "high",
  191. message: "Medical claims must be verified and include disclaimers"
  192. )
  193. end
  194. { violations: @violations, suggestions: @suggestions }
  195. end
  196. end
  197. # Use with custom validator
  198. brand = Brand.first
  199. service = Branding::ComplianceServiceV2.new(
  200. brand,
  201. "Content with medical claims",
  202. "article",
  203. { custom_validators: [CustomIndustryValidator.new(brand, "content")] }
  204. )
  205. end
  206. end
  207. end
  208. # To run the demonstration:
  209. # rails runner "Branding::ComplianceUsageExample.demonstrate"

app/services/campaign_analytics_service.rb

0.0% lines covered

317 relevant lines. 0 lines covered and 317 lines missed.
    
  1. class CampaignAnalyticsService
  2. def initialize(campaign)
  3. @campaign = campaign
  4. end
  5. def generate_comprehensive_report(period = 'daily', days = 30)
  6. start_date = days.days.ago
  7. end_date = Time.current
  8. {
  9. campaign_overview: campaign_overview,
  10. performance_summary: performance_summary(start_date, end_date),
  11. journey_performance: journey_performance_breakdown(period, days),
  12. conversion_analysis: conversion_analysis(start_date, end_date),
  13. persona_insights: persona_insights,
  14. ab_test_results: ab_test_results,
  15. recommendations: generate_recommendations,
  16. period_info: {
  17. start_date: start_date,
  18. end_date: end_date,
  19. period: period,
  20. days: days
  21. }
  22. }
  23. end
  24. def campaign_overview
  25. {
  26. id: @campaign.id,
  27. name: @campaign.name,
  28. status: @campaign.status,
  29. type: @campaign.campaign_type,
  30. persona: @campaign.persona.name,
  31. duration_days: @campaign.duration_days,
  32. total_journeys: @campaign.total_journeys,
  33. active_journeys: @campaign.active_journeys,
  34. progress_percentage: @campaign.progress_percentage
  35. }
  36. end
  37. def performance_summary(start_date, end_date)
  38. journeys = @campaign.journeys.published
  39. total_performance = @campaign.performance_summary
  40. # Aggregate journey analytics
  41. analytics = JourneyAnalytics.joins(:journey)
  42. .where(journeys: { campaign_id: @campaign.id })
  43. .where(period_start: start_date..end_date)
  44. return total_performance if analytics.empty?
  45. {
  46. total_executions: analytics.sum(:total_executions),
  47. completed_executions: analytics.sum(:completed_executions),
  48. abandoned_executions: analytics.sum(:abandoned_executions),
  49. overall_conversion_rate: analytics.average(:conversion_rate)&.round(2) || 0,
  50. overall_engagement_score: analytics.average(:engagement_score)&.round(2) || 0,
  51. average_completion_time: analytics.average(:average_completion_time)&.round(2) || 0,
  52. trends: calculate_performance_trends(analytics)
  53. }
  54. end
  55. def journey_performance_breakdown(period = 'daily', days = 30)
  56. journeys = @campaign.journeys.published.includes(:journey_analytics)
  57. journeys.map do |journey|
  58. analytics_summary = journey.analytics_summary(days)
  59. latest_performance = journey.latest_performance_score
  60. {
  61. journey_id: journey.id,
  62. journey_name: journey.name,
  63. status: journey.status,
  64. performance_score: latest_performance,
  65. analytics: analytics_summary,
  66. funnel_data: journey.funnel_performance('default', days),
  67. ab_test_status: journey.ab_test_status
  68. }
  69. end
  70. end
  71. def conversion_analysis(start_date, end_date)
  72. funnels = ConversionFunnel.joins(:journey)
  73. .where(journeys: { campaign_id: @campaign.id })
  74. .where(period_start: start_date..end_date)
  75. .group(:funnel_name, :stage)
  76. .sum(:conversions)
  77. stage_performance = funnels.group_by { |key, _| key[1] } # Group by stage
  78. .transform_values { |stage_data| stage_data.sum { |_, conversions| conversions } }
  79. {
  80. total_conversions: funnels.values.sum,
  81. conversions_by_stage: stage_performance,
  82. funnel_efficiency: calculate_funnel_efficiency(funnels),
  83. bottlenecks: identify_conversion_bottlenecks(stage_performance)
  84. }
  85. end
  86. def persona_insights
  87. persona = @campaign.persona
  88. return {} unless persona
  89. {
  90. persona_name: persona.name,
  91. demographics_summary: persona.demographics_summary,
  92. behavior_summary: persona.behavior_summary,
  93. campaign_alignment: analyze_campaign_persona_alignment,
  94. performance_by_segment: calculate_segment_performance
  95. }
  96. end
  97. def ab_test_results
  98. tests = @campaign.ab_tests.includes(:ab_test_variants)
  99. return [] if tests.empty?
  100. tests.map do |test|
  101. {
  102. test_name: test.name,
  103. status: test.status,
  104. duration_days: test.duration_days,
  105. statistical_significance: test.statistical_significance_reached?,
  106. winner: test.winner_variant&.name,
  107. results_summary: test.results_summary,
  108. variant_comparison: test.variant_comparison,
  109. recommendation: test.recommend_action
  110. }
  111. end
  112. end
  113. def generate_recommendations
  114. recommendations = []
  115. # Performance-based recommendations
  116. performance = performance_summary(30.days.ago, Time.current)
  117. if performance[:overall_conversion_rate] < 5.0
  118. recommendations << {
  119. type: 'conversion_optimization',
  120. priority: 'high',
  121. title: 'Low Conversion Rate Detected',
  122. description: "Campaign conversion rate (#{performance[:overall_conversion_rate]}%) is below industry average (5%). Consider optimizing journey steps or messaging.",
  123. action_items: [
  124. 'Review journey flow for friction points',
  125. 'A/B test call-to-action messages',
  126. 'Analyze drop-off points in conversion funnel'
  127. ]
  128. }
  129. end
  130. if performance[:overall_engagement_score] < 60.0
  131. recommendations << {
  132. type: 'engagement_improvement',
  133. priority: 'medium',
  134. title: 'Engagement Score Below Target',
  135. description: "Engagement score (#{performance[:overall_engagement_score]}) suggests users are not fully interacting with journey content.",
  136. action_items: [
  137. 'Review content relevance to persona',
  138. 'Optimize content for mobile devices',
  139. 'Add interactive elements to journey steps'
  140. ]
  141. }
  142. end
  143. # Journey-specific recommendations
  144. journey_performances = journey_performance_breakdown
  145. low_performing_journeys = journey_performances.select { |j| j[:performance_score] < 50.0 }
  146. if low_performing_journeys.any?
  147. recommendations << {
  148. type: 'journey_optimization',
  149. priority: 'high',
  150. title: 'Underperforming Journeys Identified',
  151. description: "#{low_performing_journeys.count} journey(s) have performance scores below 50%.",
  152. action_items: [
  153. 'Review underperforming journey content',
  154. 'Consider A/B testing alternative approaches',
  155. 'Analyze persona-journey alignment'
  156. ],
  157. affected_journeys: low_performing_journeys.map { |j| j[:journey_name] }
  158. }
  159. end
  160. # A/B test recommendations
  161. ab_results = ab_test_results
  162. completed_tests = ab_results.select { |test| test[:status] == 'completed' }
  163. if completed_tests.any? { |test| test[:winner] }
  164. winners = completed_tests.select { |test| test[:winner] }.map { |test| test[:winner] }
  165. recommendations << {
  166. type: 'ab_test_implementation',
  167. priority: 'high',
  168. title: 'Implement A/B Test Winners',
  169. description: "#{winners.count} A/B test(s) have identified winning variants ready for implementation.",
  170. action_items: [
  171. 'Deploy winning variants to all traffic',
  172. 'Monitor performance after implementation',
  173. 'Plan next round of optimization tests'
  174. ],
  175. winning_variants: winners
  176. }
  177. end
  178. recommendations
  179. end
  180. def calculate_roi(investment_amount = nil)
  181. return {} unless investment_amount
  182. performance = performance_summary(30.days.ago, Time.current)
  183. total_conversions = performance[:completed_executions] || 0
  184. # This would integrate with actual revenue tracking
  185. # For now, use placeholder calculations
  186. estimated_revenue_per_conversion = @campaign.target_metrics['revenue_per_conversion'] || 100
  187. total_revenue = total_conversions * estimated_revenue_per_conversion
  188. roi_percentage = investment_amount > 0 ? ((total_revenue - investment_amount) / investment_amount * 100) : 0
  189. {
  190. investment: investment_amount,
  191. estimated_revenue: total_revenue,
  192. net_profit: total_revenue - investment_amount,
  193. roi_percentage: roi_percentage.round(1),
  194. cost_per_conversion: total_conversions > 0 ? (investment_amount / total_conversions).round(2) : 0,
  195. conversion_value: estimated_revenue_per_conversion
  196. }
  197. end
  198. def export_data(format = 'json')
  199. data = generate_comprehensive_report
  200. case format
  201. when 'csv'
  202. export_to_csv(data)
  203. when 'json'
  204. data.to_json
  205. else
  206. data
  207. end
  208. end
  209. private
  210. def calculate_performance_trends(analytics)
  211. return {} if analytics.count < 2
  212. # Calculate week-over-week trends
  213. this_week = analytics.where('period_start >= ?', 1.week.ago)
  214. last_week = analytics.where('period_start >= ? AND period_start < ?', 2.weeks.ago, 1.week.ago)
  215. return {} if this_week.empty? || last_week.empty?
  216. {
  217. conversion_rate: calculate_trend_change(
  218. last_week.average(:conversion_rate),
  219. this_week.average(:conversion_rate)
  220. ),
  221. engagement_score: calculate_trend_change(
  222. last_week.average(:engagement_score),
  223. this_week.average(:engagement_score)
  224. ),
  225. total_executions: calculate_trend_change(
  226. last_week.sum(:total_executions),
  227. this_week.sum(:total_executions)
  228. )
  229. }
  230. end
  231. def calculate_trend_change(old_value, new_value)
  232. return 0 if old_value.nil? || new_value.nil? || old_value == 0
  233. change_percentage = ((new_value - old_value) / old_value * 100).round(1)
  234. {
  235. previous_value: old_value.round(2),
  236. current_value: new_value.round(2),
  237. change_percentage: change_percentage,
  238. trend: change_percentage > 5 ? 'up' : (change_percentage < -5 ? 'down' : 'stable')
  239. }
  240. end
  241. def calculate_funnel_efficiency(funnels)
  242. return {} if funnels.empty?
  243. stage_totals = funnels.group_by { |key, _| key[1] } # Group by stage
  244. .transform_values { |stage_data| stage_data.sum { |_, conversions| conversions } }
  245. stages = Journey::STAGES
  246. efficiencies = {}
  247. stages.each_with_index do |stage, index|
  248. next if index == 0 # Skip first stage
  249. previous_stage = stages[index - 1]
  250. current_conversions = stage_totals[stage] || 0
  251. previous_conversions = stage_totals[previous_stage] || 0
  252. efficiency = previous_conversions > 0 ? (current_conversions.to_f / previous_conversions * 100).round(1) : 0
  253. efficiencies["#{previous_stage}_to_#{stage}"] = efficiency
  254. end
  255. efficiencies
  256. end
  257. def identify_conversion_bottlenecks(stage_performance)
  258. return [] if stage_performance.empty?
  259. sorted_stages = stage_performance.sort_by { |_, conversions| conversions }
  260. lowest_performing = sorted_stages.first(2)
  261. lowest_performing.map do |stage, conversions|
  262. {
  263. stage: stage,
  264. conversions: conversions,
  265. severity: conversions < (stage_performance.values.sum / stage_performance.count) * 0.5 ? 'high' : 'medium'
  266. }
  267. end
  268. end
  269. def analyze_campaign_persona_alignment
  270. # Analyze how well the campaign aligns with persona preferences
  271. persona = @campaign.persona
  272. journeys = @campaign.journeys
  273. channel_alignment = analyze_channel_alignment(persona, journeys)
  274. messaging_alignment = analyze_messaging_alignment(persona, journeys)
  275. {
  276. overall_score: (channel_alignment + messaging_alignment) / 2,
  277. channel_alignment: channel_alignment,
  278. messaging_alignment: messaging_alignment,
  279. suggestions: generate_alignment_suggestions(channel_alignment, messaging_alignment)
  280. }
  281. end
  282. def analyze_channel_alignment(persona, journeys)
  283. preferred_channels = persona.preferences['channel_preferences'] || []
  284. return 70 if preferred_channels.empty? # Default score if no preferences
  285. used_channels = journeys.flat_map { |j| j.journey_steps.pluck(:channel) }.compact.uniq
  286. matching_channels = (preferred_channels & used_channels).count
  287. total_preferred = preferred_channels.count
  288. total_preferred > 0 ? (matching_channels.to_f / total_preferred * 100).round : 70
  289. end
  290. def analyze_messaging_alignment(persona, journeys)
  291. preferred_tone = persona.preferences['messaging_tone']
  292. return 70 unless preferred_tone # Default score if no preference
  293. # This would analyze actual journey content for tone
  294. # For now, return a placeholder score
  295. 75
  296. end
  297. def generate_alignment_suggestions(channel_score, messaging_score)
  298. suggestions = []
  299. if channel_score < 60
  300. suggestions << "Consider incorporating more preferred channels from persona profile"
  301. end
  302. if messaging_score < 60
  303. suggestions << "Review messaging tone to better match persona preferences"
  304. end
  305. if channel_score > 80 && messaging_score > 80
  306. suggestions << "Strong persona alignment - maintain current approach"
  307. end
  308. suggestions
  309. end
  310. def calculate_segment_performance
  311. # This would break down performance by demographic segments
  312. # For now, return placeholder data
  313. {
  314. age_segments: {
  315. '18-25' => { conversion_rate: 4.2, engagement_score: 78 },
  316. '26-35' => { conversion_rate: 6.1, engagement_score: 82 },
  317. '36-45' => { conversion_rate: 5.8, engagement_score: 75 }
  318. },
  319. location_segments: {
  320. 'urban' => { conversion_rate: 5.9, engagement_score: 80 },
  321. 'suburban' => { conversion_rate: 5.2, engagement_score: 76 },
  322. 'rural' => { conversion_rate: 4.8, engagement_score: 72 }
  323. }
  324. }
  325. end
  326. def export_to_csv(data)
  327. # This would convert the analytics data to CSV format
  328. # Implementation would depend on specific CSV requirements
  329. "CSV export functionality would be implemented here"
  330. end
  331. end

app/services/journey/brand_compliance_service.rb

0.0% lines covered

415 relevant lines. 0 lines covered and 415 lines missed.
    
  1. module Journey
  2. class BrandComplianceService
  3. include ActiveSupport::Configurable
  4. config_accessor :default_compliance_level, default: :standard
  5. config_accessor :cache_results, default: true
  6. config_accessor :async_processing, default: false
  7. config_accessor :broadcast_violations, default: true
  8. attr_reader :journey, :step, :brand, :content, :content_type, :context, :results
  9. # Content types specific to journey steps
  10. JOURNEY_CONTENT_TYPES = {
  11. 'email' => 'email_content',
  12. 'blog_post' => 'blog_content',
  13. 'social_post' => 'social_media_content',
  14. 'landing_page' => 'web_content',
  15. 'video' => 'video_script',
  16. 'webinar' => 'presentation_content',
  17. 'advertisement' => 'advertising_content',
  18. 'newsletter' => 'email_content'
  19. }.freeze
  20. def initialize(journey:, step: nil, content:, context: {})
  21. @journey = journey
  22. @step = step
  23. @brand = journey.brand
  24. @content = content
  25. @context = context.with_indifferent_access
  26. @content_type = determine_content_type
  27. @results = {}
  28. validate_initialization
  29. end
  30. # Main method to check compliance for journey content
  31. def check_compliance(options = {})
  32. return no_brand_compliance_result unless brand.present?
  33. compliance_options = build_compliance_options(options)
  34. # Create compliance service instance
  35. compliance_service = Branding::ComplianceServiceV2.new(
  36. brand,
  37. content,
  38. @content_type,
  39. compliance_options
  40. )
  41. # Perform compliance check
  42. @results = compliance_service.check_compliance
  43. # Add journey-specific metadata
  44. enhance_results_with_journey_context
  45. # Store compliance insights
  46. store_compliance_insights if options[:store_insights] != false
  47. # Broadcast real-time updates
  48. broadcast_compliance_results if config.broadcast_violations
  49. @results
  50. rescue StandardError => e
  51. handle_compliance_error(e)
  52. end
  53. # Pre-generation compliance check for suggested content
  54. def pre_generation_check(suggested_content, options = {})
  55. return { allowed: true, suggestions: [] } unless brand.present?
  56. # Quick compliance check for content suggestions
  57. compliance_options = build_compliance_options(options.merge(
  58. generate_suggestions: false,
  59. cache_results: false
  60. ))
  61. compliance_service = Branding::ComplianceServiceV2.new(
  62. brand,
  63. suggested_content,
  64. @content_type,
  65. compliance_options
  66. )
  67. results = compliance_service.check_compliance
  68. {
  69. allowed: results[:compliant],
  70. score: results[:score],
  71. violations: results[:violations] || [],
  72. suggestions: results[:suggestions] || [],
  73. quick_check: true
  74. }
  75. end
  76. # Validate content against specific brand aspects
  77. def validate_aspects(aspects, options = {})
  78. return no_brand_compliance_result unless brand.present?
  79. compliance_options = build_compliance_options(options)
  80. compliance_service = Branding::ComplianceServiceV2.new(
  81. brand,
  82. content,
  83. @content_type,
  84. compliance_options
  85. )
  86. @results = compliance_service.check_specific_aspects(aspects)
  87. enhance_results_with_journey_context
  88. @results
  89. end
  90. # Auto-fix compliance violations
  91. def auto_fix_violations(options = {})
  92. return no_brand_compliance_result unless brand.present?
  93. compliance_options = build_compliance_options(options)
  94. compliance_service = Branding::ComplianceServiceV2.new(
  95. brand,
  96. content,
  97. @content_type,
  98. compliance_options
  99. )
  100. fix_results = compliance_service.validate_and_fix
  101. if fix_results[:fixed_content].present?
  102. @content = fix_results[:fixed_content]
  103. end
  104. @results = fix_results
  105. enhance_results_with_journey_context
  106. @results
  107. end
  108. # Get compliance recommendations for improving the content
  109. def get_recommendations(options = {})
  110. return { recommendations: [] } unless brand.present?
  111. # First check current compliance
  112. compliance_results = check_compliance(options)
  113. # Get intelligent suggestions for improvements
  114. compliance_service = Branding::ComplianceServiceV2.new(
  115. brand,
  116. content,
  117. @content_type,
  118. build_compliance_options(options)
  119. )
  120. recommendations = compliance_service.preview_fixes(compliance_results[:violations])
  121. {
  122. current_score: compliance_results[:score],
  123. recommendations: recommendations,
  124. priority_fixes: filter_priority_recommendations(recommendations),
  125. estimated_improvement: calculate_estimated_improvement(recommendations)
  126. }
  127. end
  128. # Check if content meets minimum compliance threshold
  129. def meets_minimum_compliance?(threshold = nil)
  130. results = check_compliance
  131. threshold ||= compliance_threshold_for_level(config.default_compliance_level)
  132. results[:score] >= threshold && results[:compliant]
  133. end
  134. # Get compliance score without full validation
  135. def quick_score
  136. return 1.0 unless brand.present?
  137. compliance_service = Branding::ComplianceServiceV2.new(
  138. brand,
  139. content,
  140. @content_type,
  141. { generate_suggestions: false, cache_results: true }
  142. )
  143. results = compliance_service.check_compliance
  144. results[:score] || 0.0
  145. end
  146. # Get brand-specific validation rules for the content type
  147. def applicable_brand_rules
  148. return [] unless brand.present?
  149. brand.brand_guidelines
  150. .active
  151. .where(category: content_category_mapping)
  152. .or(brand.brand_guidelines.active.where(rule_type: 'universal'))
  153. .order(priority: :desc)
  154. end
  155. # Check if specific messaging is allowed
  156. def messaging_allowed?(message_text)
  157. return true unless brand&.messaging_framework.present?
  158. framework = brand.messaging_framework
  159. # Check for banned words
  160. banned_words = framework.banned_words || []
  161. contains_banned = banned_words.any? { |word| message_text.downcase.include?(word.downcase) }
  162. # Check tone compliance
  163. tone_compliant = check_message_tone_compliance(message_text, framework.tone_attributes || {})
  164. !contains_banned && tone_compliant
  165. end
  166. private
  167. def validate_initialization
  168. raise ArgumentError, "Journey is required" unless journey.present?
  169. raise ArgumentError, "Content is required" unless content.present?
  170. end
  171. def determine_content_type
  172. if step.present?
  173. JOURNEY_CONTENT_TYPES[step.content_type] || step.content_type || 'general'
  174. else
  175. context[:content_type] || 'general'
  176. end
  177. end
  178. def build_compliance_options(options = {})
  179. base_options = {
  180. compliance_level: config.default_compliance_level,
  181. async: config.async_processing,
  182. generate_suggestions: true,
  183. real_time_updates: config.broadcast_violations,
  184. cache_results: config.cache_results,
  185. channel: step&.channel || context[:channel],
  186. audience: journey.target_audience,
  187. campaign_context: build_campaign_context
  188. }
  189. base_options.merge(options)
  190. end
  191. def build_campaign_context
  192. {
  193. journey_id: journey.id,
  194. journey_name: journey.name,
  195. campaign_type: journey.campaign_type,
  196. journey_stage: step&.stage,
  197. step_position: step&.position,
  198. target_audience: journey.target_audience,
  199. goals: journey.goals
  200. }
  201. end
  202. def enhance_results_with_journey_context
  203. return unless @results.is_a?(Hash)
  204. @results[:journey_context] = {
  205. journey_id: journey.id,
  206. journey_name: journey.name,
  207. step_id: step&.id,
  208. step_name: step&.name,
  209. content_type: @content_type,
  210. checked_at: Time.current
  211. }
  212. # Add step-specific recommendations
  213. if step.present?
  214. @results[:step_recommendations] = generate_step_specific_recommendations
  215. end
  216. # Add journey-level compliance trends
  217. @results[:compliance_trend] = calculate_journey_compliance_trend
  218. end
  219. def generate_step_specific_recommendations
  220. recommendations = []
  221. # Recommend content types that perform better for this stage
  222. if step.stage.present?
  223. stage_recommendations = get_stage_specific_recommendations(step.stage)
  224. recommendations.concat(stage_recommendations)
  225. end
  226. # Recommend channels with better brand compliance
  227. if step.channel.present?
  228. channel_recommendations = get_channel_specific_recommendations(step.channel)
  229. recommendations.concat(channel_recommendations)
  230. end
  231. recommendations.uniq
  232. end
  233. def get_stage_specific_recommendations(stage)
  234. case stage
  235. when 'awareness'
  236. [
  237. 'Focus on brand storytelling and value proposition',
  238. 'Use approved brand messaging for first impressions',
  239. 'Ensure visual consistency with brand guidelines'
  240. ]
  241. when 'consideration'
  242. [
  243. 'Highlight key differentiators from messaging framework',
  244. 'Use case studies that align with brand voice',
  245. 'Maintain consistent tone across comparison content'
  246. ]
  247. when 'conversion'
  248. [
  249. 'Use approved call-to-action phrases',
  250. 'Ensure urgency messaging aligns with brand tone',
  251. 'Maintain brand voice in promotional content'
  252. ]
  253. when 'retention'
  254. [
  255. 'Use consistent brand voice in ongoing communications',
  256. 'Apply brand guidelines to support content',
  257. 'Maintain visual brand consistency'
  258. ]
  259. when 'advocacy'
  260. [
  261. 'Encourage brand-aligned testimonials',
  262. 'Use consistent brand messaging in referral content',
  263. 'Ensure social sharing aligns with brand guidelines'
  264. ]
  265. else
  266. []
  267. end
  268. end
  269. def get_channel_specific_recommendations(channel)
  270. case channel
  271. when 'email'
  272. ['Ensure email templates follow brand visual guidelines', 'Use approved email signature and branding']
  273. when 'social_media', 'facebook', 'instagram', 'twitter', 'linkedin'
  274. ['Use brand-approved hashtags', 'Maintain consistent visual style', 'Follow social media brand guidelines']
  275. when 'website'
  276. ['Ensure web content follows brand typography', 'Use approved color schemes', 'Follow brand content guidelines']
  277. else
  278. []
  279. end
  280. end
  281. def calculate_journey_compliance_trend
  282. return nil unless journey.journey_steps.any?
  283. # Get recent compliance scores for this journey
  284. recent_insights = journey.journey_insights
  285. .where(insights_type: 'brand_compliance')
  286. .where('calculated_at >= ?', 7.days.ago)
  287. .order(calculated_at: :desc)
  288. .limit(10)
  289. return nil if recent_insights.empty?
  290. scores = recent_insights.map { |insight| insight.data['score'] }.compact
  291. return nil if scores.empty?
  292. {
  293. average_score: scores.sum.to_f / scores.length,
  294. trend: calculate_trend(scores),
  295. total_checks: scores.length,
  296. latest_score: scores.first
  297. }
  298. end
  299. def calculate_trend(scores)
  300. return 'stable' if scores.length < 2
  301. recent_avg = scores.first(3).sum.to_f / [scores.first(3).length, 1].max
  302. older_avg = scores.last(3).sum.to_f / [scores.last(3).length, 1].max
  303. diff = recent_avg - older_avg
  304. if diff > 0.05
  305. 'improving'
  306. elsif diff < -0.05
  307. 'declining'
  308. else
  309. 'stable'
  310. end
  311. end
  312. def store_compliance_insights
  313. return unless journey.present?
  314. insight_data = {
  315. score: @results[:score],
  316. compliant: @results[:compliant],
  317. violations_count: (@results[:violations] || []).length,
  318. suggestions_count: (@results[:suggestions] || []).length,
  319. content_type: @content_type,
  320. step_id: step&.id,
  321. brand_id: brand&.id,
  322. detailed_results: @results.except(:journey_context)
  323. }
  324. journey.journey_insights.create!(
  325. insights_type: 'brand_compliance',
  326. data: insight_data,
  327. calculated_at: Time.current,
  328. expires_at: 7.days.from_now,
  329. metadata: {
  330. brand_name: brand&.name,
  331. content_length: content.length,
  332. step_name: step&.name
  333. }
  334. )
  335. rescue => e
  336. Rails.logger.error "Failed to store compliance insights: #{e.message}"
  337. end
  338. def broadcast_compliance_results
  339. return unless journey.present? && brand.present?
  340. ActionCable.server.broadcast(
  341. "journey_compliance_#{journey.id}",
  342. {
  343. event: 'compliance_check_complete',
  344. journey_id: journey.id,
  345. step_id: step&.id,
  346. brand_id: brand.id,
  347. compliant: @results[:compliant],
  348. score: @results[:score],
  349. violations_count: (@results[:violations] || []).length,
  350. timestamp: Time.current
  351. }
  352. )
  353. rescue => e
  354. Rails.logger.error "Failed to broadcast compliance results: #{e.message}"
  355. end
  356. def no_brand_compliance_result
  357. {
  358. compliant: true,
  359. score: 1.0,
  360. summary: "No brand guidelines to check against",
  361. violations: [],
  362. suggestions: [],
  363. journey_context: {
  364. journey_id: journey.id,
  365. no_brand: true
  366. }
  367. }
  368. end
  369. def handle_compliance_error(error)
  370. Rails.logger.error "Journey compliance check failed: #{error.message}"
  371. Rails.logger.error error.backtrace.join("\n")
  372. {
  373. compliant: false,
  374. error: error.message,
  375. error_type: error.class.name,
  376. score: 0.0,
  377. violations: [],
  378. suggestions: [],
  379. summary: "Compliance check failed due to an error",
  380. journey_context: {
  381. journey_id: journey.id,
  382. error_occurred: true
  383. }
  384. }
  385. end
  386. def filter_priority_recommendations(recommendations)
  387. return [] unless recommendations.is_a?(Hash)
  388. recommendations.select do |_, recommendation|
  389. recommendation[:confidence] > 0.7 && recommendation[:impact] == 'high'
  390. end
  391. end
  392. def calculate_estimated_improvement(recommendations)
  393. return 0.0 unless recommendations.is_a?(Hash)
  394. # Estimate improvement based on number and confidence of recommendations
  395. high_impact_fixes = recommendations.count { |_, rec| rec[:confidence] > 0.8 }
  396. medium_impact_fixes = recommendations.count { |_, rec| rec[:confidence] > 0.5 && rec[:confidence] <= 0.8 }
  397. # Rough improvement estimation
  398. (high_impact_fixes * 0.15) + (medium_impact_fixes * 0.08)
  399. end
  400. def compliance_threshold_for_level(level)
  401. case level.to_sym
  402. when :strict then 0.95
  403. when :standard then 0.85
  404. when :flexible then 0.70
  405. when :advisory then 0.50
  406. else 0.85
  407. end
  408. end
  409. def content_category_mapping
  410. case @content_type
  411. when 'email_content', 'newsletter'
  412. 'messaging'
  413. when 'social_media_content', 'social_post'
  414. 'social_media'
  415. when 'web_content', 'landing_page'
  416. 'website'
  417. when 'advertising_content'
  418. 'advertising'
  419. when 'video_script'
  420. 'multimedia'
  421. else
  422. 'general'
  423. end
  424. end
  425. def check_message_tone_compliance(message_text, tone_attributes)
  426. return true if tone_attributes.empty?
  427. content = message_text.downcase
  428. # Check formality level
  429. if tone_attributes['formality'] == 'formal'
  430. informal_patterns = ['hey', 'yeah', 'cool', 'awesome', 'gonna', 'wanna', '!', 'lol', 'omg']
  431. return false if informal_patterns.any? { |pattern| content.include?(pattern) }
  432. elsif tone_attributes['formality'] == 'casual'
  433. formal_patterns = ['utilize', 'facilitate', 'endeavor', 'subsequently', 'henceforth']
  434. return false if formal_patterns.any? { |pattern| content.include?(pattern) }
  435. end
  436. # Check style requirements
  437. if tone_attributes['style'] == 'professional'
  438. unprofessional_patterns = ['slang', 'yo', 'dude', 'bro', 'sick', 'lit']
  439. return false if unprofessional_patterns.any? { |pattern| content.include?(pattern) }
  440. end
  441. true
  442. end
  443. end
  444. end

app/services/journey/brand_integration_service.rb

0.0% lines covered

731 relevant lines. 0 lines covered and 731 lines missed.
    
  1. module Journey
  2. class BrandIntegrationService
  3. include ActiveSupport::Configurable
  4. config_accessor :enable_real_time_validation, default: true
  5. config_accessor :enable_auto_suggestions, default: true
  6. config_accessor :compliance_check_threshold, default: 0.7
  7. config_accessor :auto_fix_enabled, default: false
  8. attr_reader :journey, :user, :integration_context
  9. def initialize(journey:, user: nil, context: {})
  10. @journey = journey
  11. @user = user || journey.user
  12. @integration_context = context.with_indifferent_access
  13. @results = {}
  14. end
  15. # Main orchestration method for brand-aware journey operations
  16. def orchestrate_brand_journey_flow(operation:, **options)
  17. case operation.to_sym
  18. when :generate_suggestions
  19. orchestrate_brand_aware_suggestions(options)
  20. when :validate_content
  21. orchestrate_content_validation(options)
  22. when :auto_enhance_compliance
  23. orchestrate_compliance_enhancement(options)
  24. when :analyze_brand_performance
  25. orchestrate_brand_performance_analysis(options)
  26. when :sync_brand_updates
  27. orchestrate_brand_sync(options)
  28. else
  29. raise ArgumentError, "Unknown operation: #{operation}"
  30. end
  31. end
  32. # Generate brand-aware journey suggestions
  33. def orchestrate_brand_aware_suggestions(options = {})
  34. return no_brand_suggestions_result unless journey.brand.present?
  35. # Initialize suggestion engine with brand context
  36. suggestion_engine = JourneySuggestionEngine.new(
  37. journey: journey,
  38. user: user,
  39. current_step: options[:current_step],
  40. provider: options[:provider] || :openai
  41. )
  42. # Generate suggestions with brand filtering
  43. raw_suggestions = suggestion_engine.generate_suggestions(options[:filters] || {})
  44. # Apply additional brand compliance filtering
  45. compliant_suggestions = filter_suggestions_for_brand_compliance(raw_suggestions)
  46. # Enhance suggestions with brand-specific recommendations
  47. enhanced_suggestions = enhance_suggestions_with_brand_insights(compliant_suggestions)
  48. # Store integration results
  49. store_integration_insights('brand_aware_suggestions', {
  50. total_suggestions: raw_suggestions.length,
  51. compliant_suggestions: compliant_suggestions.length,
  52. enhanced_suggestions: enhanced_suggestions.length,
  53. suggestions: enhanced_suggestions
  54. })
  55. {
  56. success: true,
  57. suggestions: enhanced_suggestions,
  58. brand_integration: {
  59. brand_filtered: raw_suggestions.length - compliant_suggestions.length,
  60. brand_enhanced: enhanced_suggestions.length - compliant_suggestions.length,
  61. compliance_applied: true
  62. }
  63. }
  64. rescue => e
  65. handle_integration_error(e, 'suggestion_generation')
  66. end
  67. # Validate journey content against brand guidelines
  68. def orchestrate_content_validation(options = {})
  69. return no_brand_validation_result unless journey.brand.present?
  70. validation_results = []
  71. steps_to_validate = determine_validation_scope(options)
  72. steps_to_validate.each do |step|
  73. compliance_service = Journey::BrandComplianceService.new(
  74. journey: journey,
  75. step: step,
  76. content: step.description || step.name,
  77. context: build_step_context(step)
  78. )
  79. step_result = compliance_service.check_compliance(options[:compliance_options] || {})
  80. step_result[:step_id] = step.id
  81. step_result[:step_name] = step.name
  82. validation_results << step_result
  83. end
  84. # Calculate overall journey compliance
  85. overall_compliance = calculate_overall_journey_compliance(validation_results)
  86. # Generate actionable recommendations
  87. recommendations = generate_journey_compliance_recommendations(validation_results, overall_compliance)
  88. # Store validation insights
  89. store_integration_insights('content_validation', {
  90. overall_compliance: overall_compliance,
  91. step_results: validation_results,
  92. recommendations: recommendations,
  93. validated_steps: steps_to_validate.length
  94. })
  95. {
  96. success: true,
  97. overall_compliance: overall_compliance,
  98. step_results: validation_results,
  99. recommendations: recommendations,
  100. validation_summary: build_validation_summary(validation_results)
  101. }
  102. rescue => e
  103. handle_integration_error(e, 'content_validation')
  104. end
  105. # Auto-enhance journey content for better brand compliance
  106. def orchestrate_compliance_enhancement(options = {})
  107. return no_brand_enhancement_result unless journey.brand.present? && config.auto_fix_enabled
  108. enhancement_results = []
  109. steps_to_enhance = determine_enhancement_scope(options)
  110. steps_to_enhance.each do |step|
  111. compliance_service = Journey::BrandComplianceService.new(
  112. journey: journey,
  113. step: step,
  114. content: step.description || step.name,
  115. context: build_step_context(step)
  116. )
  117. # Check current compliance
  118. current_compliance = compliance_service.check_compliance
  119. if current_compliance[:score] < config.compliance_check_threshold
  120. # Attempt auto-fix
  121. fix_result = compliance_service.auto_fix_violations
  122. if fix_result[:fixed_content].present?
  123. # Update step with fixed content
  124. step.update!(description: fix_result[:fixed_content])
  125. enhancement_results << {
  126. step_id: step.id,
  127. step_name: step.name,
  128. enhanced: true,
  129. original_score: current_compliance[:score],
  130. improved_score: compliance_service.quick_score,
  131. fixes_applied: fix_result[:fixes_applied] || []
  132. }
  133. else
  134. enhancement_results << {
  135. step_id: step.id,
  136. step_name: step.name,
  137. enhanced: false,
  138. original_score: current_compliance[:score],
  139. issues: current_compliance[:violations] || []
  140. }
  141. end
  142. else
  143. enhancement_results << {
  144. step_id: step.id,
  145. step_name: step.name,
  146. enhanced: false,
  147. original_score: current_compliance[:score],
  148. already_compliant: true
  149. }
  150. end
  151. end
  152. # Store enhancement insights
  153. store_integration_insights('compliance_enhancement', {
  154. enhancement_results: enhancement_results,
  155. steps_processed: steps_to_enhance.length,
  156. steps_enhanced: enhancement_results.count { |r| r[:enhanced] }
  157. })
  158. {
  159. success: true,
  160. enhancement_results: enhancement_results,
  161. summary: build_enhancement_summary(enhancement_results)
  162. }
  163. rescue => e
  164. handle_integration_error(e, 'compliance_enhancement')
  165. end
  166. # Analyze brand performance across the journey
  167. def orchestrate_brand_performance_analysis(options = {})
  168. return no_brand_analysis_result unless journey.brand.present?
  169. analysis_period = options[:period_days] || 30
  170. # Gather brand compliance analytics
  171. compliance_summary = journey.brand_compliance_summary(analysis_period)
  172. compliance_by_step = journey.brand_compliance_by_step(analysis_period)
  173. violations_breakdown = journey.brand_violations_breakdown(analysis_period)
  174. # Analyze brand health trends
  175. brand_health = journey.overall_brand_health_score
  176. compliance_trend = journey.brand_compliance_trend(analysis_period)
  177. alerts = journey.brand_compliance_alerts
  178. # Generate insights and recommendations
  179. performance_insights = generate_brand_performance_insights(
  180. compliance_summary,
  181. compliance_by_step,
  182. violations_breakdown,
  183. brand_health,
  184. compliance_trend
  185. )
  186. recommendations = generate_brand_performance_recommendations(
  187. performance_insights,
  188. alerts
  189. )
  190. # Store performance analysis
  191. store_integration_insights('brand_performance_analysis', {
  192. analysis_period: analysis_period,
  193. compliance_summary: compliance_summary,
  194. brand_health_score: brand_health,
  195. compliance_trend: compliance_trend,
  196. insights: performance_insights,
  197. recommendations: recommendations,
  198. alerts: alerts
  199. })
  200. {
  201. success: true,
  202. brand_health_score: brand_health,
  203. compliance_trend: compliance_trend,
  204. compliance_summary: compliance_summary,
  205. compliance_by_step: compliance_by_step,
  206. violations_breakdown: violations_breakdown,
  207. insights: performance_insights,
  208. recommendations: recommendations,
  209. alerts: alerts
  210. }
  211. rescue => e
  212. handle_integration_error(e, 'brand_performance_analysis')
  213. end
  214. # Sync journey content with updated brand guidelines
  215. def orchestrate_brand_sync(options = {})
  216. return no_brand_sync_result unless journey.brand.present?
  217. sync_results = []
  218. updated_guidelines = options[:updated_guidelines] || []
  219. # If no specific guidelines provided, sync all active guidelines
  220. if updated_guidelines.empty?
  221. updated_guidelines = journey.brand.brand_guidelines.active.pluck(:id)
  222. end
  223. # Re-validate all journey steps against updated guidelines
  224. journey.journey_steps.each do |step|
  225. compliance_service = Journey::BrandComplianceService.new(
  226. journey: journey,
  227. step: step,
  228. content: step.description || step.name,
  229. context: build_step_context(step)
  230. )
  231. # Check compliance with updated guidelines
  232. updated_compliance = compliance_service.check_compliance(
  233. compliance_level: :standard,
  234. force_refresh: true
  235. )
  236. # Compare with previous compliance if available
  237. previous_check = step.latest_compliance_check
  238. previous_score = previous_check&.data&.dig('score') || 0.0
  239. sync_results << {
  240. step_id: step.id,
  241. step_name: step.name,
  242. previous_score: previous_score,
  243. updated_score: updated_compliance[:score],
  244. score_change: updated_compliance[:score] - previous_score,
  245. new_violations: updated_compliance[:violations] || [],
  246. requires_attention: updated_compliance[:score] < config.compliance_check_threshold
  247. }
  248. end
  249. # Generate sync recommendations
  250. sync_recommendations = generate_sync_recommendations(sync_results)
  251. # Store sync insights
  252. store_integration_insights('brand_sync', {
  253. synced_guidelines: updated_guidelines,
  254. sync_results: sync_results,
  255. steps_requiring_attention: sync_results.count { |r| r[:requires_attention] },
  256. recommendations: sync_recommendations
  257. })
  258. {
  259. success: true,
  260. sync_results: sync_results,
  261. steps_requiring_attention: sync_results.count { |r| r[:requires_attention] },
  262. recommendations: sync_recommendations,
  263. summary: build_sync_summary(sync_results)
  264. }
  265. rescue => e
  266. handle_integration_error(e, 'brand_sync')
  267. end
  268. # Get integration health status
  269. def integration_health_check
  270. return { healthy: false, reason: 'No brand associated' } unless journey.brand.present?
  271. health_indicators = {
  272. brand_setup: check_brand_setup_health,
  273. journey_compliance: check_journey_compliance_health,
  274. integration_performance: check_integration_performance_health,
  275. recent_activity: check_recent_activity_health
  276. }
  277. overall_health = health_indicators.values.all? { |indicator| indicator[:healthy] }
  278. {
  279. healthy: overall_health,
  280. indicators: health_indicators,
  281. recommendations: overall_health ? [] : generate_health_recommendations(health_indicators)
  282. }
  283. end
  284. private
  285. def filter_suggestions_for_brand_compliance(suggestions)
  286. return suggestions unless journey.brand.present?
  287. suggestions.select do |suggestion|
  288. # Filter based on brand compliance score
  289. compliance_score = suggestion['brand_compliance_score'] || 0.5
  290. compliance_score >= config.compliance_check_threshold
  291. end
  292. end
  293. def enhance_suggestions_with_brand_insights(suggestions)
  294. return suggestions unless journey.brand.present?
  295. brand_context = extract_brand_enhancement_context
  296. suggestions.map do |suggestion|
  297. enhanced_suggestion = suggestion.dup
  298. # Add brand-specific enhancements
  299. enhanced_suggestion['brand_enhancements'] = generate_brand_enhancements(suggestion, brand_context)
  300. enhanced_suggestion['brand_compliance_tips'] = generate_compliance_tips(suggestion, brand_context)
  301. enhanced_suggestion
  302. end
  303. end
  304. def extract_brand_enhancement_context
  305. brand = journey.brand
  306. {
  307. messaging_framework: brand.messaging_framework,
  308. recent_guidelines: brand.brand_guidelines.active.order(updated_at: :desc).limit(5),
  309. voice_attributes: brand.brand_voice_attributes,
  310. industry_context: brand.industry
  311. }
  312. end
  313. def generate_brand_enhancements(suggestion, brand_context)
  314. enhancements = []
  315. # Messaging framework enhancements
  316. if brand_context[:messaging_framework]&.key_messages.present?
  317. relevant_messages = find_relevant_key_messages(suggestion, brand_context[:messaging_framework])
  318. if relevant_messages.any?
  319. enhancements << {
  320. type: 'key_messaging',
  321. recommendation: "Consider incorporating: #{relevant_messages.join(', ')}",
  322. priority: 'high'
  323. }
  324. end
  325. end
  326. # Voice attribute enhancements
  327. if brand_context[:voice_attributes].present?
  328. voice_recommendations = generate_voice_recommendations(suggestion, brand_context[:voice_attributes])
  329. enhancements.concat(voice_recommendations)
  330. end
  331. enhancements
  332. end
  333. def generate_compliance_tips(suggestion, brand_context)
  334. tips = []
  335. # Content type specific tips
  336. content_type = suggestion['content_type']
  337. case content_type
  338. when 'email'
  339. tips << "Ensure email signature includes brand elements"
  340. tips << "Use approved email templates if available"
  341. when 'social_post'
  342. tips << "Include brand hashtags where appropriate"
  343. tips << "Follow social media brand voice guidelines"
  344. when 'blog_post'
  345. tips << "Include brand storytelling elements"
  346. tips << "Use brand-approved images and formatting"
  347. end
  348. # Channel specific tips
  349. channel = suggestion['channel']
  350. if channel == 'website'
  351. tips << "Ensure consistent with website brand guidelines"
  352. tips << "Use approved fonts and color schemes"
  353. end
  354. tips.uniq
  355. end
  356. def find_relevant_key_messages(suggestion, messaging_framework)
  357. # Simple keyword matching - could be enhanced with NLP
  358. suggestion_text = "#{suggestion['name']} #{suggestion['description']}".downcase
  359. relevant_messages = []
  360. messaging_framework.key_messages.each do |category, messages|
  361. messages.each do |message|
  362. if suggestion_text.include?(message.downcase) ||
  363. message.downcase.split.any? { |word| suggestion_text.include?(word) }
  364. relevant_messages << message
  365. end
  366. end
  367. end
  368. relevant_messages.uniq.first(3) # Limit to 3 most relevant
  369. end
  370. def generate_voice_recommendations(suggestion, voice_attributes)
  371. recommendations = []
  372. if voice_attributes['tone']
  373. recommendations << {
  374. type: 'tone_guidance',
  375. recommendation: "Maintain #{voice_attributes['tone']} tone throughout content",
  376. priority: 'medium'
  377. }
  378. end
  379. if voice_attributes['formality']
  380. recommendations << {
  381. type: 'formality_guidance',
  382. recommendation: "Use #{voice_attributes['formality']} language style",
  383. priority: 'medium'
  384. }
  385. end
  386. recommendations
  387. end
  388. def determine_validation_scope(options)
  389. if options[:step_ids].present?
  390. journey.journey_steps.where(id: options[:step_ids])
  391. elsif options[:stage].present?
  392. journey.journey_steps.where(stage: options[:stage])
  393. else
  394. journey.journey_steps
  395. end
  396. end
  397. def determine_enhancement_scope(options)
  398. if options[:step_ids].present?
  399. journey.journey_steps.where(id: options[:step_ids])
  400. elsif options[:low_compliance_only]
  401. # Find steps with low compliance scores
  402. step_ids_needing_enhancement = []
  403. journey.journey_steps.each do |step|
  404. if step.quick_compliance_score < config.compliance_check_threshold
  405. step_ids_needing_enhancement << step.id
  406. end
  407. end
  408. journey.journey_steps.where(id: step_ids_needing_enhancement)
  409. else
  410. journey.journey_steps
  411. end
  412. end
  413. def build_step_context(step)
  414. {
  415. step_id: step.id,
  416. step_type: step.content_type,
  417. channel: step.channel,
  418. stage: step.stage,
  419. position: step.position,
  420. journey_context: {
  421. campaign_type: journey.campaign_type,
  422. target_audience: journey.target_audience
  423. }
  424. }
  425. end
  426. def calculate_overall_journey_compliance(validation_results)
  427. return { score: 1.0, compliant: true } if validation_results.empty?
  428. scores = validation_results.map { |result| result[:score] || 0.0 }
  429. average_score = scores.sum / scores.length
  430. compliant_count = validation_results.count { |result| result[:compliant] }
  431. {
  432. score: average_score.round(3),
  433. compliant: compliant_count == validation_results.length,
  434. compliant_steps: compliant_count,
  435. total_steps: validation_results.length,
  436. compliance_rate: (compliant_count.to_f / validation_results.length * 100).round(1)
  437. }
  438. end
  439. def generate_journey_compliance_recommendations(validation_results, overall_compliance)
  440. recommendations = []
  441. # Overall recommendations
  442. if overall_compliance[:score] < 0.8
  443. recommendations << {
  444. type: 'overall_improvement',
  445. priority: 'high',
  446. message: 'Journey has low brand compliance overall',
  447. action: 'Review and update content across multiple steps'
  448. }
  449. end
  450. # Step-specific recommendations
  451. validation_results.each do |result|
  452. next if result[:compliant]
  453. recommendations << {
  454. type: 'step_improvement',
  455. priority: result[:score] < 0.5 ? 'high' : 'medium',
  456. step_id: result[:step_id],
  457. step_name: result[:step_name],
  458. message: "Step has #{result[:violations]&.length || 0} brand violations",
  459. action: 'Review content against brand guidelines'
  460. }
  461. end
  462. recommendations
  463. end
  464. def generate_brand_performance_insights(compliance_summary, compliance_by_step, violations_breakdown, brand_health, compliance_trend)
  465. insights = []
  466. # Compliance trend insight
  467. case compliance_trend
  468. when 'improving'
  469. insights << {
  470. type: 'positive_trend',
  471. message: 'Brand compliance is improving over time',
  472. impact: 'Brand consistency is strengthening'
  473. }
  474. when 'declining'
  475. insights << {
  476. type: 'negative_trend',
  477. message: 'Brand compliance is declining',
  478. impact: 'Brand consistency may be weakening'
  479. }
  480. end
  481. # Step performance insights
  482. if compliance_by_step.any?
  483. worst_performing_step = compliance_by_step.min_by { |_, data| data[:average_score] }
  484. best_performing_step = compliance_by_step.max_by { |_, data| data[:average_score] }
  485. if worst_performing_step[1][:average_score] < 0.6
  486. insights << {
  487. type: 'step_performance',
  488. message: "Step ID #{worst_performing_step[0]} has consistently low compliance",
  489. impact: 'May negatively affect brand perception'
  490. }
  491. end
  492. if best_performing_step[1][:average_score] > 0.9
  493. insights << {
  494. type: 'step_success',
  495. message: "Step ID #{best_performing_step[0]} maintains excellent brand compliance",
  496. impact: 'Can serve as a template for other steps'
  497. }
  498. end
  499. end
  500. # Violation pattern insights
  501. if violations_breakdown[:by_category].any?
  502. most_common_violation = violations_breakdown[:by_category].max_by { |_, count| count }
  503. insights << {
  504. type: 'violation_pattern',
  505. message: "Most common violation type: #{most_common_violation[0]}",
  506. impact: 'Focus improvement efforts on this area'
  507. }
  508. end
  509. insights
  510. end
  511. def generate_brand_performance_recommendations(insights, alerts)
  512. recommendations = []
  513. # Convert alerts to recommendations
  514. alerts.each do |alert|
  515. recommendations << {
  516. type: alert[:type],
  517. priority: alert[:severity],
  518. message: alert[:message],
  519. action: alert[:recommendation]
  520. }
  521. end
  522. # Add insight-based recommendations
  523. insights.each do |insight|
  524. case insight[:type]
  525. when 'negative_trend'
  526. recommendations << {
  527. type: 'trend_improvement',
  528. priority: 'high',
  529. message: 'Address declining compliance trend',
  530. action: 'Audit recent content changes and reinforce brand guidelines'
  531. }
  532. when 'violation_pattern'
  533. recommendations << {
  534. type: 'pattern_fix',
  535. priority: 'medium',
  536. message: 'Address common violation pattern',
  537. action: "Focus on improving #{insight[:message].split(': ').last} compliance"
  538. }
  539. end
  540. end
  541. recommendations.uniq { |r| [r[:type], r[:message]] }
  542. end
  543. def generate_sync_recommendations(sync_results)
  544. recommendations = []
  545. # Find steps that need immediate attention
  546. critical_steps = sync_results.select { |r| r[:requires_attention] && r[:updated_score] < 0.5 }
  547. if critical_steps.any?
  548. recommendations << {
  549. type: 'critical_fixes',
  550. priority: 'high',
  551. message: "#{critical_steps.length} steps require immediate attention",
  552. action: 'Review and fix critical brand violations',
  553. step_ids: critical_steps.map { |s| s[:step_id] }
  554. }
  555. end
  556. # Find steps with significant score decreases
  557. declining_steps = sync_results.select { |r| r[:score_change] < -0.2 }
  558. if declining_steps.any?
  559. recommendations << {
  560. type: 'score_decline',
  561. priority: 'medium',
  562. message: "#{declining_steps.length} steps show significant compliance decline",
  563. action: 'Investigate what changed in brand guidelines',
  564. step_ids: declining_steps.map { |s| s[:step_id] }
  565. }
  566. end
  567. recommendations
  568. end
  569. def store_integration_insights(operation_type, data)
  570. journey.journey_insights.create!(
  571. insights_type: 'brand_integration',
  572. data: data.merge(
  573. operation_type: operation_type,
  574. integration_timestamp: Time.current,
  575. brand_id: journey.brand&.id
  576. ),
  577. calculated_at: Time.current,
  578. expires_at: 7.days.from_now,
  579. metadata: {
  580. service: 'BrandIntegrationService',
  581. user_id: user&.id,
  582. context: integration_context
  583. }
  584. )
  585. rescue => e
  586. Rails.logger.error "Failed to store integration insights: #{e.message}"
  587. end
  588. def build_validation_summary(validation_results)
  589. return {} if validation_results.empty?
  590. {
  591. total_steps: validation_results.length,
  592. compliant_steps: validation_results.count { |r| r[:compliant] },
  593. average_score: (validation_results.sum { |r| r[:score] || 0.0 } / validation_results.length).round(3),
  594. total_violations: validation_results.sum { |r| (r[:violations] || []).length }
  595. }
  596. end
  597. def build_enhancement_summary(enhancement_results)
  598. return {} if enhancement_results.empty?
  599. enhanced_count = enhancement_results.count { |r| r[:enhanced] }
  600. {
  601. total_steps: enhancement_results.length,
  602. enhanced_steps: enhanced_count,
  603. enhancement_rate: (enhanced_count.to_f / enhancement_results.length * 100).round(1),
  604. average_improvement: calculate_average_improvement(enhancement_results)
  605. }
  606. end
  607. def build_sync_summary(sync_results)
  608. return {} if sync_results.empty?
  609. {
  610. total_steps: sync_results.length,
  611. steps_requiring_attention: sync_results.count { |r| r[:requires_attention] },
  612. average_score_change: (sync_results.sum { |r| r[:score_change] } / sync_results.length).round(3),
  613. improved_steps: sync_results.count { |r| r[:score_change] > 0 },
  614. declined_steps: sync_results.count { |r| r[:score_change] < 0 }
  615. }
  616. end
  617. def calculate_average_improvement(enhancement_results)
  618. enhanced_results = enhancement_results.select { |r| r[:enhanced] && r[:improved_score] && r[:original_score] }
  619. return 0.0 if enhanced_results.empty?
  620. improvements = enhanced_results.map { |r| r[:improved_score] - r[:original_score] }
  621. (improvements.sum / improvements.length).round(3)
  622. end
  623. def check_brand_setup_health
  624. brand = journey.brand
  625. issues = []
  626. issues << "No messaging framework" unless brand.messaging_framework.present?
  627. issues << "No active brand guidelines" unless brand.brand_guidelines.active.any?
  628. issues << "No brand voice attributes" unless brand.brand_voice_attributes.present?
  629. { healthy: issues.empty?, issues: issues }
  630. end
  631. def check_journey_compliance_health
  632. compliance_summary = journey.brand_compliance_summary(7)
  633. if compliance_summary.empty?
  634. { healthy: false, issues: ["No recent compliance checks"] }
  635. elsif compliance_summary[:average_score] < 0.7
  636. { healthy: false, issues: ["Low average compliance score: #{compliance_summary[:average_score]}"] }
  637. else
  638. { healthy: true, issues: [] }
  639. end
  640. end
  641. def check_integration_performance_health
  642. recent_insights = journey.journey_insights
  643. .where(insights_type: 'brand_integration')
  644. .where('calculated_at >= ?', 24.hours.ago)
  645. if recent_insights.empty?
  646. { healthy: false, issues: ["No recent integration activity"] }
  647. else
  648. { healthy: true, issues: [] }
  649. end
  650. end
  651. def check_recent_activity_health
  652. recent_updates = journey.journey_steps.where('updated_at >= ?', 24.hours.ago)
  653. if recent_updates.any?
  654. # Check if recent updates maintained compliance
  655. low_compliance_updates = recent_updates.select { |step| step.quick_compliance_score < 0.7 }
  656. if low_compliance_updates.any?
  657. { healthy: false, issues: ["Recent updates decreased compliance"] }
  658. else
  659. { healthy: true, issues: [] }
  660. end
  661. else
  662. { healthy: true, issues: [] }
  663. end
  664. end
  665. def generate_health_recommendations(health_indicators)
  666. recommendations = []
  667. health_indicators.each do |indicator_name, indicator_data|
  668. next if indicator_data[:healthy]
  669. indicator_data[:issues].each do |issue|
  670. case indicator_name
  671. when :brand_setup
  672. recommendations << {
  673. type: 'brand_setup',
  674. priority: 'high',
  675. message: issue,
  676. action: get_brand_setup_action(issue)
  677. }
  678. when :journey_compliance
  679. recommendations << {
  680. type: 'compliance_improvement',
  681. priority: 'medium',
  682. message: issue,
  683. action: 'Review and improve journey content'
  684. }
  685. when :integration_performance
  686. recommendations << {
  687. type: 'integration_activity',
  688. priority: 'low',
  689. message: issue,
  690. action: 'Run brand integration operations'
  691. }
  692. when :recent_activity
  693. recommendations << {
  694. type: 'recent_compliance',
  695. priority: 'medium',
  696. message: issue,
  697. action: 'Review recent changes for brand compliance'
  698. }
  699. end
  700. end
  701. end
  702. recommendations
  703. end
  704. def get_brand_setup_action(issue)
  705. case issue
  706. when /messaging framework/
  707. 'Set up brand messaging framework with key messages and tone'
  708. when /brand guidelines/
  709. 'Create active brand guidelines for content validation'
  710. when /voice attributes/
  711. 'Define brand voice attributes and tone guidelines'
  712. else
  713. 'Complete brand setup'
  714. end
  715. end
  716. def handle_integration_error(error, operation)
  717. Rails.logger.error "Brand integration error in #{operation}: #{error.message}"
  718. Rails.logger.error error.backtrace.join("\n")
  719. {
  720. success: false,
  721. error: error.message,
  722. error_type: error.class.name,
  723. operation: operation,
  724. timestamp: Time.current
  725. }
  726. end
  727. def no_brand_suggestions_result
  728. {
  729. success: true,
  730. suggestions: [],
  731. brand_integration: {
  732. brand_filtered: 0,
  733. brand_enhanced: 0,
  734. compliance_applied: false,
  735. message: 'No brand associated with journey'
  736. }
  737. }
  738. end
  739. def no_brand_validation_result
  740. {
  741. success: true,
  742. overall_compliance: { score: 1.0, compliant: true },
  743. step_results: [],
  744. recommendations: [],
  745. validation_summary: {},
  746. message: 'No brand guidelines to validate against'
  747. }
  748. end
  749. def no_brand_enhancement_result
  750. {
  751. success: true,
  752. enhancement_results: [],
  753. summary: {},
  754. message: 'No brand guidelines for enhancement or auto-fix disabled'
  755. }
  756. end
  757. def no_brand_analysis_result
  758. {
  759. success: true,
  760. brand_health_score: 1.0,
  761. compliance_trend: 'stable',
  762. insights: [],
  763. recommendations: [],
  764. alerts: [],
  765. message: 'No brand associated for analysis'
  766. }
  767. end
  768. def no_brand_sync_result
  769. {
  770. success: true,
  771. sync_results: [],
  772. recommendations: [],
  773. summary: {},
  774. message: 'No brand guidelines to sync'
  775. }
  776. end
  777. end
  778. end

app/services/journey_comparison_service.rb

0.0% lines covered

412 relevant lines. 0 lines covered and 412 lines missed.
    
  1. class JourneyComparisonService
  2. def initialize(journey_ids)
  3. @journey_ids = Array(journey_ids)
  4. @journeys = Journey.where(id: @journey_ids).includes(:journey_analytics, :journey_metrics, :campaign, :persona)
  5. end
  6. def compare_performance(period = 'daily', days = 30)
  7. return { error: 'Need at least 2 journeys to compare' } if @journeys.count < 2
  8. {
  9. comparison_overview: comparison_overview,
  10. performance_metrics: compare_performance_metrics(period, days),
  11. conversion_funnels: compare_conversion_funnels(days),
  12. engagement_analysis: compare_engagement_metrics(period, days),
  13. recommendations: generate_comparison_recommendations,
  14. statistical_analysis: statistical_significance_analysis,
  15. period_info: {
  16. period: period,
  17. days: days,
  18. start_date: days.days.ago,
  19. end_date: Time.current
  20. }
  21. }
  22. end
  23. def comparison_overview
  24. @journeys.map do |journey|
  25. {
  26. id: journey.id,
  27. name: journey.name,
  28. status: journey.status,
  29. campaign: journey.campaign&.name,
  30. persona: journey.campaign&.persona&.name,
  31. total_steps: journey.total_steps,
  32. created_at: journey.created_at,
  33. performance_score: journey.latest_performance_score
  34. }
  35. end
  36. end
  37. def compare_performance_metrics(period = 'daily', days = 30)
  38. start_date = days.days.ago
  39. end_date = Time.current
  40. metrics_comparison = {}
  41. @journeys.each do |journey|
  42. analytics = journey.journey_analytics
  43. .where(period_start: start_date..end_date)
  44. .where(aggregation_period: period)
  45. if analytics.any?
  46. metrics_comparison[journey.id] = {
  47. journey_name: journey.name,
  48. total_executions: analytics.sum(:total_executions),
  49. completed_executions: analytics.sum(:completed_executions),
  50. abandoned_executions: analytics.sum(:abandoned_executions),
  51. average_conversion_rate: analytics.average(:conversion_rate)&.round(2) || 0,
  52. average_engagement_score: analytics.average(:engagement_score)&.round(2) || 0,
  53. average_completion_time: analytics.average(:average_completion_time)&.round(2) || 0,
  54. completion_rate: calculate_completion_rate(analytics),
  55. abandonment_rate: calculate_abandonment_rate(analytics)
  56. }
  57. else
  58. metrics_comparison[journey.id] = default_metrics(journey)
  59. end
  60. end
  61. # Add relative performance rankings
  62. add_performance_rankings(metrics_comparison)
  63. end
  64. def compare_conversion_funnels(days = 30)
  65. start_date = days.days.ago
  66. end_date = Time.current
  67. funnel_comparison = {}
  68. @journeys.each do |journey|
  69. funnel_data = journey.funnel_performance('default', days)
  70. if funnel_data.any?
  71. funnel_comparison[journey.id] = {
  72. journey_name: journey.name,
  73. funnel_overview: funnel_data,
  74. stage_breakdown: analyze_funnel_stages(funnel_data),
  75. bottlenecks: identify_journey_bottlenecks(funnel_data)
  76. }
  77. else
  78. funnel_comparison[journey.id] = {
  79. journey_name: journey.name,
  80. funnel_overview: {},
  81. stage_breakdown: {},
  82. bottlenecks: []
  83. }
  84. end
  85. end
  86. # Compare funnel efficiency across journeys
  87. funnel_comparison[:cross_journey_analysis] = analyze_cross_journey_funnels(funnel_comparison)
  88. funnel_comparison
  89. end
  90. def compare_engagement_metrics(period = 'daily', days = 30)
  91. engagement_comparison = {}
  92. @journeys.each do |journey|
  93. metrics = JourneyMetrics.get_journey_dashboard_metrics(journey.id, period)
  94. engagement_metrics = metrics.select { |metric_name, _|
  95. JourneyMetrics::ENGAGEMENT_METRICS.include?(metric_name)
  96. }
  97. engagement_comparison[journey.id] = {
  98. journey_name: journey.name,
  99. engagement_metrics: engagement_metrics,
  100. engagement_score: calculate_overall_engagement_score(engagement_metrics),
  101. engagement_trends: JourneyMetrics.get_metric_trend(journey.id, 'engagement_score', 7, period)
  102. }
  103. end
  104. # Rank journeys by engagement
  105. engagement_comparison[:rankings] = rank_by_engagement(engagement_comparison)
  106. engagement_comparison
  107. end
  108. def statistical_significance_analysis
  109. return {} if @journeys.count != 2
  110. journey1, journey2 = @journeys
  111. # Get recent analytics for both journeys
  112. analytics1 = journey1.journey_analytics.recent.limit(10)
  113. analytics2 = journey2.journey_analytics.recent.limit(10)
  114. return {} if analytics1.empty? || analytics2.empty?
  115. {
  116. conversion_rate_significance: calculate_metric_significance(
  117. analytics1.pluck(:conversion_rate),
  118. analytics2.pluck(:conversion_rate),
  119. 'conversion_rate'
  120. ),
  121. engagement_score_significance: calculate_metric_significance(
  122. analytics1.pluck(:engagement_score),
  123. analytics2.pluck(:engagement_score),
  124. 'engagement_score'
  125. ),
  126. execution_volume_significance: calculate_metric_significance(
  127. analytics1.pluck(:total_executions),
  128. analytics2.pluck(:total_executions),
  129. 'total_executions'
  130. ),
  131. overall_assessment: generate_significance_assessment(analytics1, analytics2)
  132. }
  133. end
  134. def generate_comparison_recommendations
  135. return [] if @journeys.count < 2
  136. recommendations = []
  137. performance_metrics = compare_performance_metrics
  138. # Find best and worst performers
  139. best_performer = performance_metrics.max_by { |_, metrics| metrics[:average_conversion_rate] }
  140. worst_performer = performance_metrics.min_by { |_, metrics| metrics[:average_conversion_rate] }
  141. if best_performer && worst_performer && best_performer[0] != worst_performer[0]
  142. best_journey = @journeys.find(best_performer[0])
  143. worst_journey = @journeys.find(worst_performer[0])
  144. conversion_diff = best_performer[1][:average_conversion_rate] - worst_performer[1][:average_conversion_rate]
  145. if conversion_diff > 2.0
  146. recommendations << {
  147. type: 'optimization_opportunity',
  148. priority: 'high',
  149. title: 'Significant Performance Gap Identified',
  150. description: "#{best_journey.name} outperforms #{worst_journey.name} by #{conversion_diff.round(1)}% conversion rate.",
  151. action_items: [
  152. "Analyze successful elements from #{best_journey.name}",
  153. "Consider A/B testing best practices from high-performer",
  154. "Review journey flow differences for optimization opportunities"
  155. ],
  156. best_performer: best_journey.name,
  157. worst_performer: worst_journey.name
  158. }
  159. end
  160. end
  161. # Engagement analysis recommendations
  162. engagement_comparison = compare_engagement_metrics
  163. low_engagement_journeys = engagement_comparison.select do |journey_id, data|
  164. next false if journey_id == :rankings
  165. data[:engagement_score] < 60
  166. end
  167. if low_engagement_journeys.any?
  168. recommendations << {
  169. type: 'engagement_improvement',
  170. priority: 'medium',
  171. title: 'Low Engagement Detected',
  172. description: "#{low_engagement_journeys.count} journey(s) have engagement scores below 60%.",
  173. action_items: [
  174. 'Review content relevance and quality',
  175. 'Analyze user interaction patterns',
  176. 'Consider personalizing content based on persona'
  177. ],
  178. affected_journeys: low_engagement_journeys.map { |_, data| data[:journey_name] }
  179. }
  180. end
  181. # Funnel analysis recommendations
  182. funnel_comparison = compare_conversion_funnels
  183. journeys_with_bottlenecks = funnel_comparison.select do |journey_id, data|
  184. next false if journey_id == :cross_journey_analysis
  185. data[:bottlenecks].any?
  186. end
  187. if journeys_with_bottlenecks.any?
  188. recommendations << {
  189. type: 'funnel_optimization',
  190. priority: 'high',
  191. title: 'Conversion Bottlenecks Identified',
  192. description: "Multiple journeys have identified conversion bottlenecks that may be limiting performance.",
  193. action_items: [
  194. 'Focus on optimizing identified bottleneck stages',
  195. 'Consider alternative approaches for problematic steps',
  196. 'Implement progressive disclosure for complex steps'
  197. ],
  198. bottleneck_details: journeys_with_bottlenecks.map do |journey_id, data|
  199. {
  200. journey: data[:journey_name],
  201. bottlenecks: data[:bottlenecks]
  202. }
  203. end
  204. }
  205. end
  206. recommendations
  207. end
  208. def self.benchmark_against_industry(journey, industry_metrics = {})
  209. # This would compare journey metrics against industry benchmarks
  210. # For now, use default benchmarks
  211. default_benchmarks = {
  212. conversion_rate: 5.0,
  213. engagement_score: 70.0,
  214. completion_rate: 65.0,
  215. abandonment_rate: 35.0
  216. }
  217. benchmarks = industry_metrics.empty? ? default_benchmarks : industry_metrics
  218. journey_metrics = journey.analytics_summary(30)
  219. return {} if journey_metrics.empty?
  220. comparison = {}
  221. benchmarks.each do |metric, benchmark_value|
  222. journey_value = case metric
  223. when :conversion_rate
  224. journey_metrics[:average_conversion_rate]
  225. when :completion_rate
  226. journey_metrics[:completed_executions].to_f /
  227. [journey_metrics[:total_executions], 1].max * 100
  228. when :abandonment_rate
  229. journey_metrics[:abandoned_executions].to_f /
  230. [journey_metrics[:total_executions], 1].max * 100
  231. else
  232. journey_metrics[metric] || 0
  233. end
  234. performance_rating = if journey_value >= benchmark_value * 1.2
  235. 'excellent'
  236. elsif journey_value >= benchmark_value
  237. 'above_average'
  238. elsif journey_value >= benchmark_value * 0.8
  239. 'average'
  240. else
  241. 'below_average'
  242. end
  243. comparison[metric] = {
  244. journey_value: journey_value.round(2),
  245. benchmark_value: benchmark_value,
  246. difference: (journey_value - benchmark_value).round(2),
  247. performance_rating: performance_rating
  248. }
  249. end
  250. comparison
  251. end
  252. private
  253. def calculate_completion_rate(analytics)
  254. total_executions = analytics.sum(:total_executions)
  255. completed_executions = analytics.sum(:completed_executions)
  256. return 0 if total_executions == 0
  257. (completed_executions.to_f / total_executions * 100).round(2)
  258. end
  259. def calculate_abandonment_rate(analytics)
  260. total_executions = analytics.sum(:total_executions)
  261. abandoned_executions = analytics.sum(:abandoned_executions)
  262. return 0 if total_executions == 0
  263. (abandoned_executions.to_f / total_executions * 100).round(2)
  264. end
  265. def default_metrics(journey)
  266. {
  267. journey_name: journey.name,
  268. total_executions: 0,
  269. completed_executions: 0,
  270. abandoned_executions: 0,
  271. average_conversion_rate: 0,
  272. average_engagement_score: 0,
  273. average_completion_time: 0,
  274. completion_rate: 0,
  275. abandonment_rate: 0
  276. }
  277. end
  278. def add_performance_rankings(metrics_comparison)
  279. # Rank journeys by conversion rate
  280. sorted_by_conversion = metrics_comparison.sort_by { |_, metrics| -metrics[:average_conversion_rate] }
  281. sorted_by_conversion.each_with_index do |(journey_id, metrics), index|
  282. metrics[:conversion_rate_rank] = index + 1
  283. end
  284. # Rank by engagement score
  285. sorted_by_engagement = metrics_comparison.sort_by { |_, metrics| -metrics[:average_engagement_score] }
  286. sorted_by_engagement.each_with_index do |(journey_id, metrics), index|
  287. metrics[:engagement_score_rank] = index + 1
  288. end
  289. # Calculate overall performance rank
  290. metrics_comparison.each do |journey_id, metrics|
  291. overall_score = (metrics[:average_conversion_rate] * 0.4 +
  292. metrics[:average_engagement_score] * 0.3 +
  293. metrics[:completion_rate] * 0.3)
  294. metrics[:overall_performance_score] = overall_score.round(2)
  295. end
  296. sorted_by_overall = metrics_comparison.sort_by { |_, metrics| -metrics[:overall_performance_score] }
  297. sorted_by_overall.each_with_index do |(journey_id, metrics), index|
  298. metrics[:overall_rank] = index + 1
  299. end
  300. metrics_comparison
  301. end
  302. def analyze_funnel_stages(funnel_data)
  303. return {} unless funnel_data[:stages]
  304. stages = funnel_data[:stages]
  305. stage_analysis = {}
  306. stages.each_with_index do |stage, index|
  307. next_stage = stages[index + 1]
  308. stage_analysis[stage[:stage]] = {
  309. conversion_rate: stage[:conversion_rate],
  310. drop_off_rate: stage[:drop_off_rate],
  311. visitors: stage[:visitors],
  312. conversions: stage[:conversions],
  313. efficiency: next_stage ?
  314. (next_stage[:visitors].to_f / stage[:conversions] * 100).round(1) : 100
  315. }
  316. end
  317. stage_analysis
  318. end
  319. def identify_journey_bottlenecks(funnel_data)
  320. return [] unless funnel_data[:stages]
  321. stages = funnel_data[:stages]
  322. bottlenecks = []
  323. stages.each do |stage|
  324. if stage[:drop_off_rate] > 50
  325. bottlenecks << {
  326. stage: stage[:stage],
  327. drop_off_rate: stage[:drop_off_rate],
  328. severity: stage[:drop_off_rate] > 70 ? 'high' : 'medium'
  329. }
  330. end
  331. end
  332. bottlenecks
  333. end
  334. def analyze_cross_journey_funnels(funnel_comparison)
  335. return {} if funnel_comparison.empty?
  336. stage_performance = {}
  337. Journey::STAGES.each do |stage|
  338. stage_data = []
  339. funnel_comparison.each do |journey_id, data|
  340. next if journey_id == :cross_journey_analysis
  341. stage_breakdown = data[:stage_breakdown][stage]
  342. if stage_breakdown
  343. stage_data << {
  344. journey_id: journey_id,
  345. journey_name: data[:journey_name],
  346. conversion_rate: stage_breakdown[:conversion_rate],
  347. drop_off_rate: stage_breakdown[:drop_off_rate]
  348. }
  349. end
  350. end
  351. next if stage_data.empty?
  352. best_performer = stage_data.max_by { |d| d[:conversion_rate] }
  353. worst_performer = stage_data.min_by { |d| d[:conversion_rate] }
  354. stage_performance[stage] = {
  355. average_conversion_rate: (stage_data.sum { |d| d[:conversion_rate] } / stage_data.count).round(2),
  356. best_performer: best_performer,
  357. worst_performer: worst_performer,
  358. performance_spread: (best_performer[:conversion_rate] - worst_performer[:conversion_rate]).round(2)
  359. }
  360. end
  361. stage_performance
  362. end
  363. def calculate_overall_engagement_score(engagement_metrics)
  364. return 0 if engagement_metrics.empty?
  365. scores = engagement_metrics.values.map { |metric| metric[:value] || 0 }
  366. (scores.sum / scores.count).round(2)
  367. end
  368. def rank_by_engagement(engagement_comparison)
  369. engagement_scores = engagement_comparison.reject { |k, _| k == :rankings }
  370. .map { |journey_id, data| [journey_id, data[:engagement_score]] }
  371. .sort_by { |_, score| -score }
  372. rankings = {}
  373. engagement_scores.each_with_index do |(journey_id, score), index|
  374. journey_name = engagement_comparison[journey_id][:journey_name]
  375. rankings[index + 1] = {
  376. journey_id: journey_id,
  377. journey_name: journey_name,
  378. engagement_score: score
  379. }
  380. end
  381. rankings
  382. end
  383. def calculate_metric_significance(values1, values2, metric_name)
  384. return {} if values1.empty? || values2.empty?
  385. mean1 = values1.sum.to_f / values1.count
  386. mean2 = values2.sum.to_f / values2.count
  387. # Simple t-test approximation
  388. variance1 = values1.sum { |x| (x - mean1) ** 2 } / [values1.count - 1, 1].max
  389. variance2 = values2.sum { |x| (x - mean2) ** 2 } / [values2.count - 1, 1].max
  390. pooled_se = Math.sqrt(variance1 / values1.count + variance2 / values2.count)
  391. return {} if pooled_se == 0
  392. t_stat = (mean1 - mean2).abs / pooled_se
  393. # Simplified significance determination
  394. significance_level = if t_stat > 2.58
  395. 'highly_significant'
  396. elsif t_stat > 1.96
  397. 'significant'
  398. elsif t_stat > 1.64
  399. 'marginally_significant'
  400. else
  401. 'not_significant'
  402. end
  403. {
  404. metric_name: metric_name,
  405. mean1: mean1.round(2),
  406. mean2: mean2.round(2),
  407. difference: (mean1 - mean2).round(2),
  408. t_statistic: t_stat.round(3),
  409. significance_level: significance_level,
  410. sample_sizes: [values1.count, values2.count]
  411. }
  412. end
  413. def generate_significance_assessment(analytics1, analytics2)
  414. journey1_name = @journeys.first.name
  415. journey2_name = @journeys.last.name
  416. mean_conversion1 = analytics1.average(:conversion_rate) || 0
  417. mean_conversion2 = analytics2.average(:conversion_rate) || 0
  418. if (mean_conversion1 - mean_conversion2).abs < 1.0
  419. "Performance between #{journey1_name} and #{journey2_name} is statistically similar"
  420. elsif mean_conversion1 > mean_conversion2
  421. "#{journey1_name} shows significantly better conversion performance than #{journey2_name}"
  422. else
  423. "#{journey2_name} shows significantly better conversion performance than #{journey1_name}"
  424. end
  425. end
  426. end

app/services/journey_flow_engine.rb

0.0% lines covered

150 relevant lines. 0 lines covered and 150 lines missed.
    
  1. class JourneyFlowEngine
  2. attr_reader :execution, :journey, :user
  3. def initialize(execution)
  4. @execution = execution
  5. @journey = execution.journey
  6. @user = execution.user
  7. end
  8. def self.start_journey(journey, user, context = {})
  9. execution = find_or_create_execution(journey, user)
  10. engine = new(execution)
  11. engine.start!(context)
  12. end
  13. def self.find_or_create_execution(journey, user)
  14. JourneyExecution.find_or_create_by(journey: journey, user: user) do |exec|
  15. exec.execution_context = {}
  16. end
  17. end
  18. def start!(initial_context = {})
  19. return execution if execution.running? || execution.completed?
  20. # Add initial context
  21. initial_context.each { |key, value| execution.add_context(key, value) }
  22. # Find entry point
  23. entry_step = find_entry_step
  24. unless entry_step
  25. execution.fail!
  26. raise "No entry step found for journey #{journey.name}"
  27. end
  28. execution.update!(current_step: entry_step)
  29. execution.start!
  30. # Create first step execution
  31. step_execution = execution.step_executions.create!(
  32. journey_step: entry_step,
  33. started_at: Time.current,
  34. context: execution.execution_context.dup
  35. )
  36. execution
  37. end
  38. def advance!
  39. # Check if we can advance (running state and not at exit point)
  40. return false unless execution.running?
  41. return false if execution.current_step&.is_exit_point?
  42. current_step_execution = execution.step_executions
  43. .where(journey_step: execution.current_step)
  44. .last
  45. # Complete current step if not already completed
  46. if current_step_execution&.pending?
  47. current_step_execution.complete!
  48. end
  49. # Find next step based on conditions
  50. next_step = evaluate_next_step
  51. if next_step
  52. execution.update!(current_step: next_step)
  53. # Create new step execution
  54. execution.step_executions.create!(
  55. journey_step: next_step,
  56. started_at: Time.current,
  57. context: execution.execution_context.dup
  58. )
  59. # Check if this is an exit point
  60. if next_step.is_exit_point?
  61. execution.complete!
  62. end
  63. true
  64. else
  65. # No more steps - complete the journey
  66. execution.complete!
  67. false
  68. end
  69. end
  70. def pause!
  71. execution.pause! if execution.may_pause?
  72. end
  73. def resume!
  74. execution.resume! if execution.may_resume?
  75. end
  76. def fail!(reason = nil)
  77. execution.add_context('failure_reason', reason) if reason
  78. execution.fail! if execution.may_fail?
  79. end
  80. def evaluate_conditions(step, context = nil)
  81. context ||= execution.execution_context
  82. step.evaluate_conditions(context)
  83. end
  84. def get_available_next_steps
  85. return [] unless execution.current_step
  86. current_step = execution.current_step
  87. available_steps = []
  88. # Check conditional transitions first (ordered by priority)
  89. current_step.transitions_from.includes(:to_step).order(:priority).each do |transition|
  90. if transition.evaluate(execution.execution_context)
  91. available_steps << {
  92. step: transition.to_step,
  93. transition_type: transition.transition_type,
  94. conditions_met: true
  95. }
  96. break # Return only the first (highest priority) matching transition
  97. end
  98. end
  99. # If no conditional transitions, check sequential next step
  100. if available_steps.empty?
  101. next_sequential = journey.journey_steps
  102. .where('position > ?', current_step.position)
  103. .order(:position)
  104. .first
  105. if next_sequential
  106. available_steps << {
  107. step: next_sequential,
  108. transition_type: 'sequential',
  109. conditions_met: true
  110. }
  111. end
  112. end
  113. available_steps
  114. end
  115. def simulate_journey(context = {})
  116. simulation_context = execution.execution_context.merge(context)
  117. current_step = execution.current_step || find_entry_step
  118. visited_steps = []
  119. max_steps = 50 # Prevent infinite loops
  120. while current_step && visited_steps.length < max_steps
  121. visited_steps << {
  122. step: current_step,
  123. stage: current_step.stage,
  124. conditions: current_step.conditions
  125. }
  126. # Find next step based on simulation context
  127. next_step = nil
  128. current_step.transitions_from.each do |transition|
  129. if transition.evaluate(simulation_context)
  130. next_step = transition.to_step
  131. break
  132. end
  133. end
  134. # Break if we hit an exit point
  135. break if current_step.is_exit_point?
  136. # If no conditional transition, try sequential
  137. next_step ||= journey.journey_steps
  138. .where('position > ?', current_step.position)
  139. .order(:position)
  140. .first
  141. current_step = next_step
  142. end
  143. visited_steps
  144. end
  145. private
  146. def find_entry_step
  147. # First try explicit entry points
  148. entry_step = journey.journey_steps.entry_points.first
  149. # Fall back to first step by position
  150. entry_step ||= journey.journey_steps.order(:position).first
  151. entry_step
  152. end
  153. def evaluate_next_step
  154. current_step = execution.current_step
  155. return nil unless current_step
  156. # Check conditional transitions first (ordered by priority)
  157. current_step.transitions_from.includes(:to_step).order(:priority).each do |transition|
  158. if transition.evaluate(execution.execution_context)
  159. return transition.to_step
  160. end
  161. end
  162. # Fall back to sequential next step
  163. journey.journey_steps
  164. .where('position > ?', current_step.position)
  165. .order(:position)
  166. .first
  167. end
  168. end

app/services/journey_suggestion_engine.rb

0.0% lines covered

648 relevant lines. 0 lines covered and 648 lines missed.
    
  1. class JourneySuggestionEngine
  2. # AI providers configuration
  3. PROVIDERS = {
  4. openai: {
  5. api_url: 'https://api.openai.com/v1/chat/completions',
  6. model: 'gpt-4-turbo-preview',
  7. headers: ->(api_key) { { 'Authorization' => "Bearer #{api_key}", 'Content-Type' => 'application/json' } }
  8. },
  9. anthropic: {
  10. api_url: 'https://api.anthropic.com/v1/messages',
  11. model: 'claude-3-sonnet-20240229',
  12. headers: ->(api_key) { { 'x-api-key' => api_key, 'Content-Type' => 'application/json', 'anthropic-version' => '2023-06-01' } }
  13. }
  14. }.freeze
  15. FEEDBACK_TYPES = %w[suggestion_quality relevance usefulness timing channel_fit].freeze
  16. CACHE_TTL = 1.hour
  17. attr_reader :journey, :user, :current_step, :provider
  18. def initialize(journey:, user:, current_step: nil, provider: :openai)
  19. @journey = journey
  20. @user = user
  21. @current_step = current_step
  22. @provider = provider.to_sym
  23. @http_client = build_http_client
  24. end
  25. # Main method to generate contextual suggestions for the next journey step
  26. def generate_suggestions(filters = {})
  27. cache_key = build_cache_key(filters)
  28. Rails.cache.fetch(cache_key, expires_in: CACHE_TTL) do
  29. context = build_journey_context
  30. suggestions = fetch_ai_suggestions(context, filters)
  31. ranked_suggestions = rank_suggestions(suggestions, context)
  32. store_journey_insights(ranked_suggestions, context)
  33. ranked_suggestions
  34. end
  35. end
  36. # Generate suggestions for specific stage and context
  37. def suggest_for_stage(stage, filters = {})
  38. context = build_stage_context(stage)
  39. suggestions = fetch_ai_suggestions(context, filters.merge(stage: stage))
  40. rank_suggestions(suggestions, context)
  41. end
  42. # Record user feedback on suggestions for learning
  43. def record_feedback(suggested_step_data, feedback_type, rating: nil, selected: false, context: nil)
  44. return unless FEEDBACK_TYPES.include?(feedback_type)
  45. SuggestionFeedback.create!(
  46. journey: journey,
  47. journey_step: current_step,
  48. suggested_step_id: suggested_step_data[:id],
  49. user: user,
  50. feedback_type: feedback_type,
  51. rating: rating,
  52. selected: selected,
  53. context: context,
  54. metadata: {
  55. suggested_step_data: suggested_step_data,
  56. timestamp: Time.current,
  57. provider: provider
  58. }
  59. )
  60. end
  61. # Get historical feedback for learning algorithm
  62. def get_feedback_insights
  63. journey.suggestion_feedbacks
  64. .joins(:journey_step)
  65. .group(:feedback_type)
  66. .average(:rating)
  67. end
  68. private
  69. def build_http_client
  70. Faraday.new do |faraday|
  71. faraday.request :json
  72. faraday.response :json, content_type: /\bjson$/
  73. faraday.adapter Faraday.default_adapter
  74. faraday.request :retry, max: 3, interval: 0.5
  75. end
  76. end
  77. def build_journey_context
  78. base_context = {
  79. journey: {
  80. name: journey.name,
  81. description: journey.description,
  82. campaign_type: journey.campaign_type,
  83. target_audience: journey.target_audience,
  84. goals: journey.goals,
  85. current_status: journey.status,
  86. total_steps: journey.total_steps,
  87. stages_coverage: journey.steps_by_stage
  88. },
  89. current_step: current_step&.as_json(
  90. only: [:name, :description, :stage, :content_type, :channel, :duration_days],
  91. include: { next_steps: { only: [:name, :stage, :content_type] } }
  92. ),
  93. existing_steps: journey.journey_steps.by_position.map do |step|
  94. {
  95. name: step.name,
  96. stage: step.stage,
  97. content_type: step.content_type,
  98. channel: step.channel,
  99. position: step.position
  100. }
  101. end,
  102. user_preferences: extract_user_preferences,
  103. historical_performance: get_historical_performance,
  104. industry_best_practices: get_best_practices_for_campaign_type
  105. }
  106. # Add brand context if journey has an associated brand
  107. if journey.brand_id.present?
  108. base_context[:brand] = extract_brand_context
  109. end
  110. base_context
  111. end
  112. def build_stage_context(stage)
  113. build_journey_context.merge(
  114. target_stage: stage,
  115. stage_gaps: identify_stage_gaps(stage),
  116. stage_performance: get_stage_performance(stage)
  117. )
  118. end
  119. def fetch_ai_suggestions(context, filters)
  120. prompt = build_suggestion_prompt(context, filters)
  121. raw_suggestions = case provider
  122. when :openai
  123. fetch_openai_suggestions(prompt)
  124. when :anthropic
  125. fetch_anthropic_suggestions(prompt)
  126. else
  127. raise ArgumentError, "Unsupported provider: #{provider}"
  128. end
  129. # Apply brand guideline filtering if brand context is available
  130. if context[:brand].present?
  131. filter_suggestions_by_brand_guidelines(raw_suggestions, context[:brand])
  132. else
  133. raw_suggestions
  134. end
  135. rescue => e
  136. Rails.logger.error "AI suggestion generation failed: #{e.message}"
  137. generate_fallback_suggestions(context, filters)
  138. end
  139. def build_suggestion_prompt(context, filters)
  140. base_prompt = <<~PROMPT
  141. You are an expert marketing journey strategist. Based on the following journey context,
  142. suggest 3-5 highly relevant next steps that would optimize the customer journey.
  143. Journey Context:
  144. #{context.to_json}
  145. Filters Applied:
  146. #{filters.to_json}
  147. Please provide suggestions in the following JSON format:
  148. {
  149. "suggestions": [
  150. {
  151. "name": "Step name",
  152. "description": "Detailed description",
  153. "stage": "awareness|consideration|conversion|retention|advocacy",
  154. "content_type": "email|blog_post|social_post|landing_page|video|webinar|etc",
  155. "channel": "email|website|facebook|instagram|etc",
  156. "duration_days": 1-30,
  157. "reasoning": "Why this step would be effective",
  158. "confidence_score": 0.0-1.0,
  159. "expected_impact": "high|medium|low",
  160. "priority": 1-5,
  161. "best_practices": ["practice1", "practice2"],
  162. "success_metrics": ["metric1", "metric2"],
  163. "brand_compliance_score": 0.0-1.0
  164. }
  165. ]
  166. }
  167. Focus on:
  168. 1. Logical progression from current step
  169. 2. Addressing gaps in the journey stages
  170. 3. Optimizing for the stated goals
  171. 4. Leveraging successful patterns from similar campaigns
  172. 5. Considering target audience preferences
  173. PROMPT
  174. # Add brand-specific guidelines if available
  175. if context[:brand].present?
  176. base_prompt += <<~BRAND_CONTEXT
  177. BRAND COMPLIANCE REQUIREMENTS:
  178. #{format_brand_guidelines_for_prompt(context[:brand])}
  179. IMPORTANT: All suggestions must strictly adhere to brand guidelines.
  180. Include a brand_compliance_score (0.0-1.0) for each suggestion indicating
  181. how well it aligns with the brand voice, messaging, and visual guidelines.
  182. BRAND_CONTEXT
  183. end
  184. if filters[:stage]
  185. base_prompt += "\n\nSpecial focus: Generate suggestions specifically for the '#{filters[:stage]}' stage."
  186. end
  187. if filters[:content_type]
  188. base_prompt += "\n\nContent preference: Prioritize '#{filters[:content_type]}' content types."
  189. end
  190. if filters[:channel]
  191. base_prompt += "\n\nChannel preference: Focus on '#{filters[:channel]}' channel opportunities."
  192. end
  193. base_prompt
  194. end
  195. def fetch_openai_suggestions(prompt)
  196. config = PROVIDERS[:openai]
  197. api_key = Rails.application.credentials.openai_api_key
  198. return generate_fallback_suggestions({}, {}) unless api_key
  199. response = @http_client.post(config[:api_url]) do |req|
  200. req.headers.merge!(config[:headers].call(api_key))
  201. req.body = {
  202. model: config[:model],
  203. messages: [
  204. { role: 'system', content: 'You are a marketing journey optimization expert.' },
  205. { role: 'user', content: prompt }
  206. ],
  207. temperature: 0.7,
  208. max_tokens: 2000
  209. }
  210. end
  211. if response.success?
  212. content = response.body.dig('choices', 0, 'message', 'content')
  213. JSON.parse(content)['suggestions']
  214. else
  215. Rails.logger.error "OpenAI API error: #{response.body}"
  216. generate_fallback_suggestions({}, {})
  217. end
  218. end
  219. def fetch_anthropic_suggestions(prompt)
  220. config = PROVIDERS[:anthropic]
  221. api_key = Rails.application.credentials.anthropic_api_key
  222. return generate_fallback_suggestions({}, {}) unless api_key
  223. response = @http_client.post(config[:api_url]) do |req|
  224. req.headers.merge!(config[:headers].call(api_key))
  225. req.body = {
  226. model: config[:model],
  227. max_tokens: 2000,
  228. messages: [
  229. { role: 'user', content: prompt }
  230. ]
  231. }
  232. end
  233. if response.success?
  234. content = response.body.dig('content', 0, 'text')
  235. JSON.parse(content)['suggestions']
  236. else
  237. Rails.logger.error "Anthropic API error: #{response.body}"
  238. generate_fallback_suggestions({}, {})
  239. end
  240. end
  241. def rank_suggestions(suggestions, context)
  242. return suggestions unless suggestions.is_a?(Array)
  243. # Apply learning algorithm based on historical feedback
  244. feedback_insights = get_feedback_insights
  245. suggestions.map do |suggestion|
  246. base_score = suggestion['confidence_score'] || 0.5
  247. # Adjust score based on historical feedback
  248. feedback_adjustment = calculate_feedback_adjustment(suggestion, feedback_insights)
  249. # Adjust for journey completeness
  250. completeness_adjustment = calculate_completeness_adjustment(suggestion, context)
  251. # Adjust for user preferences
  252. preference_adjustment = calculate_preference_adjustment(suggestion, context)
  253. # Adjust for brand compliance if brand context is available
  254. brand_adjustment = context[:brand].present? ?
  255. calculate_brand_compliance_adjustment(suggestion, context[:brand]) : 0.0
  256. final_score = [
  257. base_score + feedback_adjustment + completeness_adjustment + preference_adjustment + brand_adjustment,
  258. 1.0
  259. ].min
  260. suggestion.merge(
  261. 'calculated_score' => final_score,
  262. 'ranking_factors' => {
  263. 'base_confidence' => base_score,
  264. 'feedback_adjustment' => feedback_adjustment,
  265. 'completeness_adjustment' => completeness_adjustment,
  266. 'preference_adjustment' => preference_adjustment,
  267. 'brand_compliance_adjustment' => brand_adjustment
  268. }
  269. )
  270. end.sort_by { |s| -s['calculated_score'] }
  271. end
  272. def calculate_feedback_adjustment(suggestion, feedback_insights)
  273. # Weight suggestions based on historical feedback for similar content types and stages
  274. content_type_rating = feedback_insights["#{suggestion['content_type']}_rating"] || 3.0
  275. stage_rating = feedback_insights["#{suggestion['stage']}_rating"] || 3.0
  276. # Convert 1-5 rating to -0.2 to +0.2 adjustment
  277. ((content_type_rating + stage_rating) / 2 - 3.0) * 0.1
  278. end
  279. def calculate_completeness_adjustment(suggestion, context)
  280. # Favor suggestions that fill gaps in the journey
  281. existing_stages = context[:journey][:stages_coverage].keys
  282. suggested_stage = suggestion['stage']
  283. # Boost score if this stage is underrepresented
  284. stage_count = context[:journey][:stages_coverage][suggested_stage] || 0
  285. total_steps = context[:journey][:total_steps] || 1
  286. if stage_count < (total_steps / 5.0) # If stage has less than 20% representation
  287. 0.15
  288. elsif stage_count == 0 # If stage is completely missing
  289. 0.25
  290. else
  291. 0.0
  292. end
  293. end
  294. def calculate_preference_adjustment(suggestion, context)
  295. # Adjust based on user's historical preferences and journey goals
  296. user_prefs = context[:user_preferences]
  297. adjustment = 0.0
  298. # Favor preferred content types
  299. if user_prefs[:preferred_content_types]&.include?(suggestion['content_type'])
  300. adjustment += 0.1
  301. end
  302. # Favor preferred channels
  303. if user_prefs[:preferred_channels]&.include?(suggestion['channel'])
  304. adjustment += 0.1
  305. end
  306. adjustment
  307. end
  308. def generate_fallback_suggestions(context, filters)
  309. # Fallback suggestions based on common patterns and templates
  310. stage = filters[:stage] || detect_next_logical_stage
  311. case stage
  312. when 'awareness'
  313. generate_awareness_suggestions
  314. when 'consideration'
  315. generate_consideration_suggestions
  316. when 'conversion'
  317. generate_conversion_suggestions
  318. when 'retention'
  319. generate_retention_suggestions
  320. when 'advocacy'
  321. generate_advocacy_suggestions
  322. else
  323. generate_general_suggestions
  324. end
  325. end
  326. def detect_next_logical_stage
  327. return 'awareness' unless current_step
  328. stage_progression = %w[awareness consideration conversion retention advocacy]
  329. current_stage_index = stage_progression.index(current_step.stage) || 0
  330. # Move to next stage or stay in current if it's the last one
  331. stage_progression[current_stage_index + 1] || current_step.stage
  332. end
  333. def generate_awareness_suggestions
  334. [
  335. {
  336. 'name' => 'Educational Blog Post',
  337. 'description' => 'Create valuable content that addresses target audience pain points',
  338. 'stage' => 'awareness',
  339. 'content_type' => 'blog_post',
  340. 'channel' => 'website',
  341. 'duration_days' => 7,
  342. 'reasoning' => 'Blog content drives organic traffic and establishes thought leadership',
  343. 'confidence_score' => 0.8,
  344. 'calculated_score' => 0.8
  345. },
  346. {
  347. 'name' => 'Social Media Campaign',
  348. 'description' => 'Engaging social content to increase brand visibility',
  349. 'stage' => 'awareness',
  350. 'content_type' => 'social_post',
  351. 'channel' => 'facebook',
  352. 'duration_days' => 3,
  353. 'reasoning' => 'Social media expands reach and engagement with target audience',
  354. 'confidence_score' => 0.75,
  355. 'calculated_score' => 0.75
  356. }
  357. ]
  358. end
  359. def generate_consideration_suggestions
  360. [
  361. {
  362. 'name' => 'Product Demo Video',
  363. 'description' => 'Showcase product features and benefits through video demonstration',
  364. 'stage' => 'consideration',
  365. 'content_type' => 'video',
  366. 'channel' => 'website',
  367. 'duration_days' => 5,
  368. 'reasoning' => 'Video content helps prospects understand product value proposition',
  369. 'confidence_score' => 0.85,
  370. 'calculated_score' => 0.85
  371. },
  372. {
  373. 'name' => 'Comparison Guide',
  374. 'description' => 'Detailed comparison of solutions to help decision making',
  375. 'stage' => 'consideration',
  376. 'content_type' => 'ebook',
  377. 'channel' => 'email',
  378. 'duration_days' => 7,
  379. 'reasoning' => 'Comparison content addresses evaluation criteria concerns',
  380. 'confidence_score' => 0.8,
  381. 'calculated_score' => 0.8
  382. }
  383. ]
  384. end
  385. def generate_conversion_suggestions
  386. [
  387. {
  388. 'name' => 'Limited Time Offer',
  389. 'description' => 'Time-sensitive promotion to encourage immediate action',
  390. 'stage' => 'conversion',
  391. 'content_type' => 'email',
  392. 'channel' => 'email',
  393. 'duration_days' => 3,
  394. 'reasoning' => 'Urgency and scarcity drive conversion behavior',
  395. 'confidence_score' => 0.9,
  396. 'calculated_score' => 0.9
  397. },
  398. {
  399. 'name' => 'Free Trial Landing Page',
  400. 'description' => 'Dedicated page optimized for trial sign-ups',
  401. 'stage' => 'conversion',
  402. 'content_type' => 'landing_page',
  403. 'channel' => 'website',
  404. 'duration_days' => 1,
  405. 'reasoning' => 'Reduces friction and focuses on conversion goal',
  406. 'confidence_score' => 0.85,
  407. 'calculated_score' => 0.85
  408. }
  409. ]
  410. end
  411. def generate_retention_suggestions
  412. [
  413. {
  414. 'name' => 'Onboarding Email Series',
  415. 'description' => 'Multi-part email series to guide new customers',
  416. 'stage' => 'retention',
  417. 'content_type' => 'email',
  418. 'channel' => 'email',
  419. 'duration_days' => 14,
  420. 'reasoning' => 'Proper onboarding increases customer lifetime value',
  421. 'confidence_score' => 0.9,
  422. 'calculated_score' => 0.9
  423. }
  424. ]
  425. end
  426. def generate_advocacy_suggestions
  427. [
  428. {
  429. 'name' => 'Customer Success Story',
  430. 'description' => 'Showcase customer achievements and testimonials',
  431. 'stage' => 'advocacy',
  432. 'content_type' => 'case_study',
  433. 'channel' => 'website',
  434. 'duration_days' => 7,
  435. 'reasoning' => 'Success stories build credibility and encourage referrals',
  436. 'confidence_score' => 0.85,
  437. 'calculated_score' => 0.85
  438. }
  439. ]
  440. end
  441. def generate_general_suggestions
  442. [
  443. {
  444. 'name' => 'Welcome Email',
  445. 'description' => 'Introductory email to new subscribers or customers',
  446. 'stage' => 'awareness',
  447. 'content_type' => 'email',
  448. 'channel' => 'email',
  449. 'duration_days' => 1,
  450. 'reasoning' => 'Sets expectations and begins relationship building',
  451. 'confidence_score' => 0.7,
  452. 'calculated_score' => 0.7
  453. }
  454. ]
  455. end
  456. def extract_user_preferences
  457. # Analyze user's historical journey patterns
  458. user_journeys = user.journeys.published
  459. {
  460. preferred_content_types: calculate_preferred_content_types(user_journeys),
  461. preferred_channels: calculate_preferred_channels(user_journeys),
  462. avg_journey_length: calculate_avg_journey_length(user_journeys),
  463. successful_patterns: identify_successful_patterns(user_journeys)
  464. }
  465. end
  466. def calculate_preferred_content_types(journeys)
  467. journeys.joins(:journey_steps)
  468. .group('journey_steps.content_type')
  469. .count
  470. .sort_by { |_, count| -count }
  471. .first(3)
  472. .map(&:first)
  473. .compact
  474. end
  475. def calculate_preferred_channels(journeys)
  476. journeys.joins(:journey_steps)
  477. .group('journey_steps.channel')
  478. .count
  479. .sort_by { |_, count| -count }
  480. .first(3)
  481. .map(&:first)
  482. .compact
  483. end
  484. def calculate_avg_journey_length(journeys)
  485. return 0 if journeys.empty?
  486. journeys.joins(:journey_steps).group(:id).count.values.sum.to_f / journeys.count
  487. end
  488. def identify_successful_patterns(journeys)
  489. # This would analyze successful journeys based on execution data
  490. # For now, return empty hash - to be implemented with analytics
  491. {}
  492. end
  493. def get_historical_performance
  494. # Analyze performance of similar journey steps
  495. # This would integrate with analytics data
  496. {}
  497. end
  498. def get_best_practices_for_campaign_type
  499. # Return best practices based on campaign type from templates
  500. return {} unless journey.campaign_type
  501. template = JourneyTemplate.where(campaign_type: journey.campaign_type)
  502. .order(usage_count: :desc)
  503. .first
  504. template&.best_practices || {}
  505. end
  506. def identify_stage_gaps(target_stage)
  507. existing_stages = journey.journey_steps.pluck(:stage).uniq
  508. all_stages = Journey::STAGES
  509. all_stages - existing_stages
  510. end
  511. def get_stage_performance(stage)
  512. # Analyze performance of steps in this stage
  513. # This would integrate with analytics data
  514. {}
  515. end
  516. def store_journey_insights(suggestions, context)
  517. JourneyInsight.create!(
  518. journey: journey,
  519. insights_type: 'ai_suggestions',
  520. data: {
  521. suggestions: suggestions,
  522. context_summary: {
  523. total_steps: context[:journey][:total_steps],
  524. stages_coverage: context[:journey][:stages_coverage],
  525. provider: provider
  526. },
  527. generated_at: Time.current
  528. },
  529. calculated_at: Time.current,
  530. expires_at: 24.hours.from_now,
  531. metadata: {
  532. provider: provider,
  533. user_id: user.id,
  534. current_step_id: current_step&.id
  535. }
  536. )
  537. end
  538. def build_cache_key(filters)
  539. key_parts = [
  540. "journey_suggestions",
  541. journey.id,
  542. journey.updated_at.to_i,
  543. current_step&.id,
  544. user.id,
  545. provider,
  546. Digest::MD5.hexdigest(filters.to_json)
  547. ]
  548. # Include brand context in cache key if available
  549. if journey.brand_id.present?
  550. key_parts << journey.brand_id
  551. key_parts << journey.brand.updated_at.to_i
  552. end
  553. key_parts.join(":")
  554. end
  555. # Brand-related helper methods
  556. def extract_brand_context
  557. brand = journey.brand
  558. return {} unless brand
  559. {
  560. id: brand.id,
  561. name: brand.name,
  562. industry: brand.industry,
  563. brand_voice: extract_brand_voice(brand),
  564. messaging_framework: extract_messaging_framework(brand),
  565. guidelines: extract_brand_guidelines(brand),
  566. color_scheme: brand.color_scheme || {},
  567. typography: brand.typography || {},
  568. visual_identity: extract_visual_identity(brand)
  569. }
  570. end
  571. def extract_brand_voice(brand)
  572. voice_data = brand.brand_voice_attributes || {}
  573. latest_analysis = brand.latest_analysis
  574. if latest_analysis&.voice_attributes.present?
  575. voice_data.merge(latest_analysis.voice_attributes)
  576. else
  577. voice_data
  578. end
  579. end
  580. def extract_messaging_framework(brand)
  581. framework = brand.messaging_framework
  582. return {} unless framework
  583. {
  584. key_messages: framework.key_messages || {},
  585. value_propositions: framework.value_propositions || {},
  586. approved_phrases: framework.approved_phrases || [],
  587. banned_words: framework.banned_words || [],
  588. tone_attributes: framework.tone_attributes || {}
  589. }
  590. end
  591. def extract_brand_guidelines(brand)
  592. guidelines = brand.brand_guidelines.active.order(priority: :desc).limit(10)
  593. guidelines.map do |guideline|
  594. {
  595. category: guideline.category,
  596. rule_type: guideline.rule_type,
  597. rule_text: guideline.rule_text,
  598. priority: guideline.priority,
  599. compliance_level: guideline.compliance_level
  600. }
  601. end
  602. end
  603. def extract_visual_identity(brand)
  604. {
  605. primary_colors: brand.primary_colors,
  606. secondary_colors: brand.secondary_colors,
  607. font_families: brand.font_families,
  608. has_brand_assets: brand.has_complete_brand_assets?
  609. }
  610. end
  611. def format_brand_guidelines_for_prompt(brand_context)
  612. guidelines_text = []
  613. # Brand voice and tone
  614. if brand_context[:brand_voice].present?
  615. guidelines_text << "Brand Voice: #{brand_context[:brand_voice].to_json}"
  616. end
  617. # Messaging framework
  618. framework = brand_context[:messaging_framework]
  619. if framework.present?
  620. guidelines_text << "Key Messages: #{framework[:key_messages].to_json}" if framework[:key_messages].present?
  621. guidelines_text << "Value Propositions: #{framework[:value_propositions].to_json}" if framework[:value_propositions].present?
  622. guidelines_text << "Approved Phrases: #{framework[:approved_phrases].join(', ')}" if framework[:approved_phrases].any?
  623. guidelines_text << "Banned Words: #{framework[:banned_words].join(', ')}" if framework[:banned_words].any?
  624. guidelines_text << "Tone Requirements: #{framework[:tone_attributes].to_json}" if framework[:tone_attributes].present?
  625. end
  626. # Brand guidelines
  627. if brand_context[:guidelines].any?
  628. guidelines_text << "Brand Guidelines:"
  629. brand_context[:guidelines].each do |guideline|
  630. guidelines_text << "- #{guideline[:category]} (#{guideline[:rule_type]}): #{guideline[:rule_text]}"
  631. end
  632. end
  633. # Visual identity
  634. visual = brand_context[:visual_identity]
  635. if visual.present?
  636. guidelines_text << "Primary Colors: #{visual[:primary_colors].join(', ')}" if visual[:primary_colors].any?
  637. guidelines_text << "Typography: #{visual[:font_families].keys.join(', ')}" if visual[:font_families].any?
  638. end
  639. guidelines_text.join("\n")
  640. end
  641. def filter_suggestions_by_brand_guidelines(suggestions, brand_context)
  642. return suggestions unless suggestions.is_a?(Array)
  643. framework = brand_context[:messaging_framework] || {}
  644. banned_words = framework[:banned_words] || []
  645. # Filter out suggestions that contain banned words
  646. filtered_suggestions = suggestions.reject do |suggestion|
  647. text_content = "#{suggestion['name']} #{suggestion['description']}".downcase
  648. banned_words.any? { |word| text_content.include?(word.downcase) }
  649. end
  650. # Add compliance warnings for potentially problematic suggestions
  651. filtered_suggestions.map do |suggestion|
  652. warnings = []
  653. # Check for tone compliance
  654. if framework[:tone_attributes].present?
  655. tone_warnings = check_tone_compliance(suggestion, framework[:tone_attributes])
  656. warnings.concat(tone_warnings)
  657. end
  658. suggestion['compliance_warnings'] = warnings if warnings.any?
  659. suggestion
  660. end
  661. end
  662. def check_tone_compliance(suggestion, tone_attributes)
  663. warnings = []
  664. content = "#{suggestion['name']} #{suggestion['description']}".downcase
  665. # Check formality level
  666. if tone_attributes['formality'] == 'formal'
  667. informal_words = ['hey', 'yeah', 'cool', 'awesome', 'gonna', 'wanna']
  668. found_informal = informal_words.select { |word| content.include?(word) }
  669. if found_informal.any?
  670. warnings << "Contains informal language: #{found_informal.join(', ')}"
  671. end
  672. elsif tone_attributes['formality'] == 'casual'
  673. formal_words = ['utilize', 'facilitate', 'endeavor', 'subsequently']
  674. found_formal = formal_words.select { |word| content.include?(word) }
  675. if found_formal.any?
  676. warnings << "Contains overly formal language: #{found_formal.join(', ')}"
  677. end
  678. end
  679. warnings
  680. end
  681. def calculate_brand_compliance_adjustment(suggestion, brand_context)
  682. return 0.0 unless brand_context.present?
  683. base_compliance_score = suggestion['brand_compliance_score'] || 0.5
  684. # Higher weight for brand compliance in scoring
  685. compliance_weight = 0.3
  686. # Convert compliance score to adjustment (-0.15 to +0.15)
  687. adjustment = (base_compliance_score - 0.5) * compliance_weight
  688. # Additional penalty for compliance warnings
  689. if suggestion['compliance_warnings']&.any?
  690. adjustment -= 0.1
  691. end
  692. adjustment
  693. end
  694. end

app/services/llm_service.rb

0.0% lines covered

338 relevant lines. 0 lines covered and 338 lines missed.
    
  1. class LlmService
  2. include Rails.application.routes.url_helpers
  3. DEFAULT_MODEL = "gpt-4-turbo-preview"
  4. DEFAULT_TEMPERATURE = 0.7
  5. DEFAULT_MAX_TOKENS = 2000
  6. # Model capabilities
  7. JSON_CAPABLE_MODELS = %w[
  8. gpt-4-turbo-preview gpt-4-1106-preview gpt-3.5-turbo-1106
  9. claude-3-opus-20240229 claude-3-sonnet-20240229 claude-3-haiku-20240307
  10. ].freeze
  11. # Provider-specific settings
  12. PROVIDER_CONFIGS = {
  13. openai: {
  14. base_url: "https://api.openai.com",
  15. models: /^(gpt|text-davinci|babbage|curie|ada)/,
  16. json_mode: true
  17. },
  18. anthropic: {
  19. base_url: "https://api.anthropic.com",
  20. models: /^claude/,
  21. json_mode: false # Claude doesn't have native JSON mode
  22. },
  23. cohere: {
  24. base_url: "https://api.cohere.ai",
  25. models: /^command/,
  26. json_mode: false
  27. },
  28. huggingface: {
  29. base_url: "https://api-inference.huggingface.co",
  30. models: /^(meta-llama|mistral|falcon)/,
  31. json_mode: false
  32. }
  33. }.freeze
  34. def initialize(model: DEFAULT_MODEL, temperature: DEFAULT_TEMPERATURE)
  35. @model = model
  36. @temperature = temperature
  37. @provider = detect_provider
  38. @client = build_client
  39. end
  40. def analyze(prompt, options = {})
  41. # Add JSON formatting instructions if requested
  42. formatted_prompt = if options[:json_response]
  43. ensure_json_response(prompt)
  44. else
  45. prompt
  46. end
  47. # Build request with retries for rate limits
  48. response = nil
  49. retries = 0
  50. max_retries = 3
  51. begin
  52. response = @client.post do |req|
  53. req.url completion_endpoint
  54. req.headers.merge!(provider_headers)
  55. req.body = build_request_body(formatted_prompt, options).to_json
  56. end
  57. parsed = parse_response(response)
  58. # If JSON was requested, validate and clean the response
  59. if options[:json_response]
  60. parsed = ensure_valid_json(parsed)
  61. end
  62. parsed
  63. rescue Faraday::TooManyRequestsError => e
  64. retries += 1
  65. if retries < max_retries
  66. wait_time = extract_retry_after(e) || (2 ** retries)
  67. Rails.logger.warn "Rate limited, waiting #{wait_time}s before retry #{retries}/#{max_retries}"
  68. sleep(wait_time)
  69. retry
  70. else
  71. handle_api_error(e)
  72. end
  73. rescue Faraday::Error => e
  74. Rails.logger.error "LLM API Error: #{e.message}"
  75. handle_api_error(e)
  76. end
  77. end
  78. def ensure_json_response(prompt)
  79. json_instruction = "\n\nIMPORTANT: You must respond with valid JSON only. Do not include any text before or after the JSON. Do not use markdown formatting. The response should be a raw JSON object that can be parsed directly."
  80. # Add JSON schema hint if the prompt mentions a structure
  81. if prompt.include?("JSON structure:")
  82. prompt + json_instruction
  83. else
  84. prompt + "\n\nProvide your response as a valid JSON object." + json_instruction
  85. end
  86. end
  87. def ensure_valid_json(response)
  88. return nil if response.nil? || response.empty?
  89. # Try to extract JSON from the response
  90. json_match = response.match(/\{.*\}/m) || response.match(/\[.*\]/m)
  91. if json_match
  92. begin
  93. JSON.parse(json_match[0])
  94. json_match[0] # Return the matched JSON string
  95. rescue JSON::ParserError => e
  96. Rails.logger.error "Invalid JSON in LLM response: #{e.message}"
  97. Rails.logger.debug "Attempted to parse: #{json_match[0][0..500]}..."
  98. response # Return original response as fallback
  99. end
  100. else
  101. Rails.logger.warn "No JSON found in LLM response"
  102. response
  103. end
  104. end
  105. def extract_retry_after(error)
  106. # Extract retry-after header if available
  107. if error.response && error.response[:headers]['retry-after']
  108. error.response[:headers]['retry-after'].to_i
  109. elsif error.response && error.response[:headers]['x-ratelimit-reset']
  110. [error.response[:headers]['x-ratelimit-reset'].to_i - Time.now.to_i, 1].max
  111. else
  112. nil
  113. end
  114. end
  115. def generate_suggestions(context, options = {})
  116. prompt = build_suggestion_prompt(context)
  117. analyze(prompt, options.merge(temperature: 0.8))
  118. end
  119. def validate_content(content, brand_guidelines, options = {})
  120. prompt = build_validation_prompt(content, brand_guidelines)
  121. analyze(prompt, options.merge(temperature: 0.3))
  122. end
  123. private
  124. def detect_provider
  125. PROVIDER_CONFIGS.find { |_, config| @model.match?(config[:models]) }&.first || :openai
  126. end
  127. def build_client
  128. Faraday.new(url: api_base_url) do |faraday|
  129. faraday.request :json
  130. faraday.response :json
  131. faraday.adapter Faraday.default_adapter
  132. # Add retry logic for network errors
  133. faraday.request :retry, {
  134. max: 3,
  135. interval: 0.5,
  136. interval_randomness: 0.5,
  137. backoff_factor: 2,
  138. exceptions: [Faraday::ConnectionFailed, Faraday::TimeoutError]
  139. }
  140. # Add timeout settings
  141. faraday.options.timeout = 120 # 2 minutes
  142. faraday.options.open_timeout = 30
  143. end
  144. end
  145. def provider_headers
  146. headers = { 'Content-Type' => 'application/json' }
  147. case @provider
  148. when :openai
  149. headers['Authorization'] = "Bearer #{api_key}"
  150. when :anthropic
  151. headers['x-api-key'] = api_key
  152. headers['anthropic-version'] = '2023-06-01'
  153. when :cohere
  154. headers['Authorization'] = "Bearer #{api_key}"
  155. when :huggingface
  156. headers['Authorization'] = "Bearer #{api_key}"
  157. else
  158. headers['Authorization'] = "Bearer #{api_key}"
  159. end
  160. headers
  161. end
  162. def api_base_url
  163. PROVIDER_CONFIGS[@provider][:base_url] || ENV['LLM_API_BASE_URL'] || "https://api.openai.com"
  164. end
  165. def api_key
  166. case @provider
  167. when :openai
  168. ENV['OPENAI_API_KEY']
  169. when :anthropic
  170. ENV['ANTHROPIC_API_KEY']
  171. when :cohere
  172. ENV['COHERE_API_KEY']
  173. when :huggingface
  174. ENV['HUGGINGFACE_API_KEY']
  175. else
  176. ENV['LLM_API_KEY'] || ENV['OPENAI_API_KEY']
  177. end
  178. end
  179. def completion_endpoint
  180. case @provider
  181. when :openai
  182. "/v1/chat/completions"
  183. when :anthropic
  184. "/v1/messages"
  185. when :cohere
  186. "/v1/generate"
  187. when :huggingface
  188. "/models/#{@model}"
  189. else
  190. "/v1/chat/completions"
  191. end
  192. end
  193. def build_request_body(prompt, options)
  194. max_tokens = options[:max_tokens] || DEFAULT_MAX_TOKENS
  195. temperature = options[:temperature] || @temperature
  196. system_message = options[:system_message] || "You are a brand analysis and marketing expert. Provide detailed, actionable insights."
  197. case @provider
  198. when :openai
  199. body = {
  200. model: @model,
  201. messages: [
  202. {
  203. role: "system",
  204. content: system_message
  205. },
  206. {
  207. role: "user",
  208. content: prompt
  209. }
  210. ],
  211. temperature: temperature,
  212. max_tokens: max_tokens
  213. }
  214. # Add JSON mode if supported and requested
  215. if options[:json_response] && JSON_CAPABLE_MODELS.include?(@model)
  216. body[:response_format] = { type: "json_object" }
  217. end
  218. body
  219. when :anthropic
  220. {
  221. model: @model,
  222. messages: [
  223. {
  224. role: "user",
  225. content: "#{system_message}\n\n#{prompt}"
  226. }
  227. ],
  228. max_tokens: max_tokens,
  229. temperature: temperature
  230. }
  231. when :cohere
  232. {
  233. model: @model,
  234. prompt: "#{system_message}\n\n#{prompt}",
  235. max_tokens: max_tokens,
  236. temperature: temperature,
  237. return_likelihoods: "NONE"
  238. }
  239. when :huggingface
  240. {
  241. inputs: prompt,
  242. parameters: {
  243. max_new_tokens: max_tokens,
  244. temperature: temperature,
  245. return_full_text: false
  246. }
  247. }
  248. else
  249. {
  250. model: @model,
  251. messages: [
  252. {
  253. role: "user",
  254. content: prompt
  255. }
  256. ],
  257. temperature: temperature,
  258. max_tokens: max_tokens
  259. }
  260. end
  261. end
  262. def parse_response(response)
  263. return nil unless response.success?
  264. case @provider
  265. when :openai
  266. response.body.dig("choices", 0, "message", "content")
  267. when :anthropic
  268. response.body.dig("content", 0, "text")
  269. when :cohere
  270. response.body.dig("generations", 0, "text") || response.body.dig("text")
  271. when :huggingface
  272. if response.body.is_a?(Array)
  273. response.body.first["generated_text"]
  274. else
  275. response.body["generated_text"]
  276. end
  277. else
  278. # Generic fallback
  279. response.body.dig("choices", 0, "message", "content") ||
  280. response.body.dig("content", 0, "text") ||
  281. response.body.dig("generations", 0, "text") ||
  282. response.body.dig("text") ||
  283. response.body["generated_text"]
  284. end
  285. end
  286. def handle_api_error(error)
  287. error_info = case error
  288. when Faraday::ResourceNotFound
  289. { error: "API endpoint not found", details: error.message, status: 404 }
  290. when Faraday::UnauthorizedError
  291. { error: "Invalid API key", details: error.message, status: 401 }
  292. when Faraday::TooManyRequestsError
  293. { error: "Rate limit exceeded", details: error.message, status: 429 }
  294. when Faraday::BadRequestError
  295. { error: "Invalid request", details: parse_error_details(error), status: 400 }
  296. when Faraday::ServerError
  297. { error: "Server error", details: error.message, status: 500 }
  298. when Faraday::TimeoutError
  299. { error: "Request timeout", details: "The request took too long to complete", status: 408 }
  300. else
  301. { error: "API request failed", details: error.message, status: 0 }
  302. end
  303. Rails.logger.error "LLM API Error: #{error_info[:error]} - #{error_info[:details]}"
  304. error_info
  305. end
  306. def parse_error_details(error)
  307. if error.response && error.response[:body]
  308. body = error.response[:body]
  309. if body.is_a?(Hash)
  310. body['error']&.[]('message') || body['message'] || error.message
  311. else
  312. error.message
  313. end
  314. else
  315. error.message
  316. end
  317. end
  318. def build_suggestion_prompt(context)
  319. <<~PROMPT
  320. Based on the following context, generate content suggestions:
  321. Brand: #{context[:brand_name]}
  322. Content Type: #{context[:content_type]}
  323. Campaign Goal: #{context[:campaign_goal]}
  324. Target Audience: #{context[:target_audience]}
  325. Brand Guidelines Summary:
  326. #{context[:guidelines_summary]}
  327. Please provide 3-5 specific content suggestions that align with the brand voice and campaign objectives.
  328. Include for each suggestion:
  329. 1. Content idea/topic
  330. 2. Key messaging points
  331. 3. Recommended format/channel
  332. 4. Expected outcome
  333. Format as JSON.
  334. PROMPT
  335. end
  336. def build_validation_prompt(content, brand_guidelines)
  337. <<~PROMPT
  338. Validate the following content against brand guidelines:
  339. Content:
  340. #{content}
  341. Brand Guidelines:
  342. #{brand_guidelines}
  343. Please analyze:
  344. 1. Brand voice compliance
  345. 2. Messaging alignment
  346. 3. Tone consistency
  347. 4. Guideline violations
  348. 5. Improvement suggestions
  349. Provide a compliance score (0-100) and detailed feedback.
  350. Format as JSON.
  351. PROMPT
  352. end
  353. end

app/services/suspicious_activity_detector.rb

0.0% lines covered

204 relevant lines. 0 lines covered and 204 lines missed.
    
  1. class SuspiciousActivityDetector
  2. attr_reader :activity
  3. # Class method for recurring job to scan all users
  4. def self.scan_all_users
  5. Rails.logger.info "Starting security scan for all users..."
  6. suspicious_users = []
  7. User.find_each do |user|
  8. # Check recent activities
  9. recent_activities = user.activities.where("occurred_at > ?", 1.hour.ago)
  10. next if recent_activities.empty?
  11. # Various suspicious pattern checks
  12. suspicious_patterns = []
  13. # Rapid requests
  14. if recent_activities.count > 200
  15. suspicious_patterns << {
  16. pattern: 'rapid_requests',
  17. value: recent_activities.count,
  18. threshold: 200
  19. }
  20. end
  21. # Multiple IPs
  22. ip_count = recent_activities.distinct.count(:ip_address)
  23. if ip_count > 5
  24. suspicious_patterns << {
  25. pattern: 'ip_hopping',
  26. value: ip_count,
  27. threshold: 5
  28. }
  29. end
  30. # Failed requests
  31. failed_count = recent_activities.failed_requests.count
  32. if failed_count > 20
  33. suspicious_patterns << {
  34. pattern: 'excessive_errors',
  35. value: failed_count,
  36. threshold: 20
  37. }
  38. end
  39. # Suspicious activities
  40. suspicious_count = recent_activities.suspicious.count
  41. if suspicious_count > 3
  42. suspicious_patterns << {
  43. pattern: 'multiple_suspicious',
  44. value: suspicious_count,
  45. threshold: 3
  46. }
  47. end
  48. if suspicious_patterns.any?
  49. suspicious_users << {
  50. user: user,
  51. patterns: suspicious_patterns,
  52. activity_count: recent_activities.count
  53. }
  54. end
  55. end
  56. # Process findings
  57. if suspicious_users.any?
  58. # Log security event
  59. ActivityLogger.security('security_scan_alert', "Security scan detected suspicious users", {
  60. user_count: suspicious_users.count,
  61. details: suspicious_users.map { |s|
  62. {
  63. user_id: s[:user].id,
  64. email: s[:user].email_address,
  65. patterns: s[:patterns].map { |p| p[:pattern] }
  66. }
  67. }
  68. })
  69. # Send alerts if configured
  70. if Rails.application.config.activity_alerts.enabled
  71. AdminMailer.security_scan_alert(suspicious_users).deliver_later
  72. end
  73. end
  74. Rails.logger.info "Security scan completed. Found #{suspicious_users.count} suspicious users."
  75. suspicious_users
  76. end
  77. SUSPICIOUS_PATTERNS = {
  78. rapid_requests: {
  79. threshold: 100, # requests
  80. window: 60 # seconds
  81. },
  82. failed_logins: {
  83. threshold: 5, # attempts
  84. window: 300 # 5 minutes
  85. },
  86. unusual_hour_activity: {
  87. start_hour: 2, # 2 AM
  88. end_hour: 5 # 5 AM
  89. },
  90. ip_hopping: {
  91. threshold: 3, # different IPs
  92. window: 300 # 5 minutes
  93. },
  94. excessive_errors: {
  95. threshold: 10, # 4xx/5xx errors
  96. window: 300 # 5 minutes
  97. }
  98. }.freeze
  99. def initialize(activity)
  100. @activity = activity
  101. end
  102. def check
  103. suspicious_reasons = []
  104. suspicious_reasons << "rapid_requests" if rapid_requests?
  105. suspicious_reasons << "failed_login_attempts" if failed_login_attempts?
  106. suspicious_reasons << "unusual_hour_activity" if unusual_hour_activity?
  107. suspicious_reasons << "ip_hopping" if ip_hopping?
  108. suspicious_reasons << "excessive_errors" if excessive_errors?
  109. suspicious_reasons << "suspicious_user_agent" if suspicious_user_agent?
  110. suspicious_reasons << "suspicious_path" if suspicious_path?
  111. if suspicious_reasons.any?
  112. mark_as_suspicious(suspicious_reasons)
  113. trigger_alert(suspicious_reasons)
  114. end
  115. suspicious_reasons.any?
  116. end
  117. private
  118. def rapid_requests?
  119. threshold = SUSPICIOUS_PATTERNS[:rapid_requests][:threshold]
  120. window = SUSPICIOUS_PATTERNS[:rapid_requests][:window]
  121. recent_count = Activity
  122. .by_user(activity.user)
  123. .where("occurred_at > ?", window.seconds.ago)
  124. .count
  125. recent_count > threshold
  126. end
  127. def failed_login_attempts?
  128. return false unless activity.controller == "sessions" && activity.action == "create" && activity.failed?
  129. threshold = SUSPICIOUS_PATTERNS[:failed_logins][:threshold]
  130. window = SUSPICIOUS_PATTERNS[:failed_logins][:window]
  131. failed_count = Activity
  132. .by_user(activity.user)
  133. .by_controller("sessions")
  134. .by_action("create")
  135. .failed_requests
  136. .where("occurred_at > ?", window.seconds.ago)
  137. .count
  138. failed_count >= threshold
  139. end
  140. def unusual_hour_activity?
  141. hour = activity.occurred_at.hour
  142. start_hour = SUSPICIOUS_PATTERNS[:unusual_hour_activity][:start_hour]
  143. end_hour = SUSPICIOUS_PATTERNS[:unusual_hour_activity][:end_hour]
  144. hour >= start_hour && hour <= end_hour
  145. end
  146. def ip_hopping?
  147. threshold = SUSPICIOUS_PATTERNS[:ip_hopping][:threshold]
  148. window = SUSPICIOUS_PATTERNS[:ip_hopping][:window]
  149. unique_ips = Activity
  150. .by_user(activity.user)
  151. .where("occurred_at > ?", window.seconds.ago)
  152. .distinct
  153. .pluck(:ip_address)
  154. .compact
  155. .size
  156. unique_ips >= threshold
  157. end
  158. def excessive_errors?
  159. threshold = SUSPICIOUS_PATTERNS[:excessive_errors][:threshold]
  160. window = SUSPICIOUS_PATTERNS[:excessive_errors][:window]
  161. error_count = Activity
  162. .by_user(activity.user)
  163. .failed_requests
  164. .where("occurred_at > ?", window.seconds.ago)
  165. .count
  166. error_count >= threshold
  167. end
  168. def suspicious_user_agent?
  169. return false unless activity.user_agent
  170. suspicious_patterns = [
  171. /bot/i,
  172. /crawler/i,
  173. /spider/i,
  174. /scraper/i,
  175. /curl/i,
  176. /wget/i,
  177. /python/i,
  178. /java/i,
  179. /ruby/i
  180. ]
  181. suspicious_patterns.any? { |pattern| activity.user_agent.match?(pattern) }
  182. end
  183. def suspicious_path?
  184. return false unless activity.request_path
  185. suspicious_paths = [
  186. /\.env/i,
  187. /config\//i,
  188. /admin/i,
  189. /wp-admin/i,
  190. /phpmyadmin/i,
  191. /\.git/i,
  192. /\.svn/i,
  193. /backup/i,
  194. /sql/i,
  195. /database/i
  196. ]
  197. # Skip if the user is actually an admin accessing admin paths
  198. return false if activity.user.admin? && activity.request_path.match?(/admin/i)
  199. suspicious_paths.any? { |pattern| activity.request_path.match?(pattern) }
  200. end
  201. def mark_as_suspicious(reasons)
  202. metadata = activity.metadata || {}
  203. metadata["suspicious_reasons"] = reasons
  204. activity.update!(
  205. suspicious: true,
  206. metadata: metadata
  207. )
  208. end
  209. def trigger_alert(reasons)
  210. # In production, this would send notifications to admins
  211. Rails.logger.warn "Suspicious activity detected for user #{activity.user.email_address}: #{reasons.join(', ')}"
  212. # Queue alert job if configured
  213. if defined?(SuspiciousActivityAlertJob)
  214. SuspiciousActivityAlertJob.perform_later(activity.id, reasons)
  215. end
  216. end
  217. end